mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-04-16 05:52:58 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f426e8999 |
4
Makefile
4
Makefile
@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
|||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.1.1-dev
|
VERSION_DEV ?= 0.1.2-dev
|
||||||
VERSION ?= 0.1.1
|
VERSION ?= 0.1.2
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := kvm
|
KVM_PKG_NAME := kvm
|
||||||
|
|||||||
57
boot_storage.go
Normal file
57
boot_storage.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BootStorageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BootStorageUnknown BootStorageType = "unknown"
|
||||||
|
BootStorageEMMC BootStorageType = "emmc"
|
||||||
|
BootStorageSD BootStorageType = "sd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bootStorageOnce sync.Once
|
||||||
|
bootStorageType BootStorageType = BootStorageUnknown
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBootStorageType() BootStorageType {
|
||||||
|
bootStorageOnce.Do(func() {
|
||||||
|
bootStorageType = detectBootStorageType()
|
||||||
|
})
|
||||||
|
return bootStorageType
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsBootFromSD() bool {
|
||||||
|
return GetBootStorageType() == BootStorageSD
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectBootStorageType() BootStorageType {
|
||||||
|
cmdlineBytes, err := os.ReadFile("/proc/cmdline")
|
||||||
|
if err != nil {
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline := strings.TrimSpace(string(cmdlineBytes))
|
||||||
|
for _, field := range strings.Fields(cmdline) {
|
||||||
|
if !strings.HasPrefix(field, "root=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := strings.TrimPrefix(field, "root=")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(root, "/dev/mmcblk0"):
|
||||||
|
return BootStorageEMMC
|
||||||
|
case strings.HasPrefix(root, "/dev/mmcblk1"):
|
||||||
|
return BootStorageSD
|
||||||
|
default:
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BootStorageUnknown
|
||||||
|
}
|
||||||
|
|
||||||
50
config.go
50
config.go
@@ -84,10 +84,12 @@ type Config struct {
|
|||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
|
UpdateDownloadProxy string `json:"update_download_proxy"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
LocalAuthToken string `json:"local_auth_token"`
|
LocalAuthToken string `json:"local_auth_token"`
|
||||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||||
|
UsbEnhancedDetection bool `json:"usb_enhanced_detection"`
|
||||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||||
KeyboardLayout string `json:"keyboard_layout"`
|
KeyboardLayout string `json:"keyboard_layout"`
|
||||||
@@ -126,6 +128,40 @@ type Config struct {
|
|||||||
WireguardAutoStart bool `json:"wireguard_autostart"`
|
WireguardAutoStart bool `json:"wireguard_autostart"`
|
||||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||||
|
Firewall *FirewallConfig `json:"firewall"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallConfig struct {
|
||||||
|
Base FirewallBaseRule `json:"base"`
|
||||||
|
Rules []FirewallRule `json:"rules"`
|
||||||
|
PortForwards []FirewallPortRule `json:"portForwards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallBaseRule struct {
|
||||||
|
InputPolicy string `json:"inputPolicy"`
|
||||||
|
OutputPolicy string `json:"outputPolicy"`
|
||||||
|
ForwardPolicy string `json:"forwardPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallRule struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
SourceIP string `json:"sourceIP"`
|
||||||
|
SourcePort *int `json:"sourcePort,omitempty"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
DestinationIP string `json:"destinationIP"`
|
||||||
|
DestinationPort *int `json:"destinationPort,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirewallPortRule struct {
|
||||||
|
Chain string `json:"chain,omitempty"`
|
||||||
|
Managed *bool `json:"managed,omitempty"`
|
||||||
|
SourcePort int `json:"sourcePort"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
DestinationIP string `json:"destinationIP"`
|
||||||
|
DestinationPort int `json:"destinationPort"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VntConfig struct {
|
type VntConfig struct {
|
||||||
@@ -160,6 +196,7 @@ var defaultConfig = &Config{
|
|||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
TLSMode: "",
|
TLSMode: "",
|
||||||
ForceHpd: false, // 默认不强制输出EDID
|
ForceHpd: false, // 默认不强制输出EDID
|
||||||
|
UsbEnhancedDetection: true,
|
||||||
UsbConfig: &usbgadget.Config{
|
UsbConfig: &usbgadget.Config{
|
||||||
VendorId: "0x1d6b", //The Linux Foundation
|
VendorId: "0x1d6b", //The Linux Foundation
|
||||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||||
@@ -191,6 +228,15 @@ var defaultConfig = &Config{
|
|||||||
AutoMountSystemInfo: true,
|
AutoMountSystemInfo: true,
|
||||||
WireguardAutoStart: false,
|
WireguardAutoStart: false,
|
||||||
NpuAppEnabled: false,
|
NpuAppEnabled: false,
|
||||||
|
Firewall: &FirewallConfig{
|
||||||
|
Base: FirewallBaseRule{
|
||||||
|
InputPolicy: "accept",
|
||||||
|
OutputPolicy: "accept",
|
||||||
|
ForwardPolicy: "accept",
|
||||||
|
},
|
||||||
|
Rules: []FirewallRule{},
|
||||||
|
PortForwards: []FirewallPortRule{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -249,6 +295,10 @@ func LoadConfig() {
|
|||||||
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if loadedConfig.Firewall == nil {
|
||||||
|
loadedConfig.Firewall = defaultConfig.Firewall
|
||||||
|
}
|
||||||
|
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||||
|
|||||||
1470
firewall.go
Normal file
1470
firewall.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,49 @@ func (s *NetworkInterfaceState) MACString() string {
|
|||||||
return s.macAddr.String()
|
return s.macAddr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NetworkInterfaceState) SetMACAddress(macAddress string) (string, error) {
|
||||||
|
macAddress = strings.TrimSpace(macAddress)
|
||||||
|
if macAddress == "" {
|
||||||
|
return "", fmt.Errorf("mac address is empty")
|
||||||
|
}
|
||||||
|
hw, err := net.ParseMAC(macAddress)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid mac address")
|
||||||
|
}
|
||||||
|
if len(hw) != 6 {
|
||||||
|
return "", fmt.Errorf("invalid mac address length")
|
||||||
|
}
|
||||||
|
normalized := strings.ToLower(hw.String())
|
||||||
|
|
||||||
|
s.stateLock.Lock()
|
||||||
|
iface, err := netlink.LinkByName(s.interfaceName)
|
||||||
|
if err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetDown(iface); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetHardwareAddr(iface, hw); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := netlink.LinkSetUp(iface); err != nil {
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.stateLock.Unlock()
|
||||||
|
|
||||||
|
if s.dhcpClient != nil && strings.TrimSpace(s.config.IPv4Mode.String) == "dhcp" {
|
||||||
|
_ = s.dhcpClient.Renew()
|
||||||
|
}
|
||||||
|
if _, err := s.update(); err != nil {
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||||
s.stateLock.Lock()
|
s.stateLock.Lock()
|
||||||
defer s.stateLock.Unlock()
|
defer s.stateLock.Unlock()
|
||||||
|
|||||||
@@ -51,14 +51,15 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUsbState returns the current state of the USB gadget
|
// GetUsbState returns the current state of the USB gadget
|
||||||
func (u *UsbGadget) GetUsbState() (state string) {
|
func (u *UsbGadget) GetUsbState(enhancedDetection bool) (state string) {
|
||||||
// Check the auxiliary disc node first
|
if enhancedDetection {
|
||||||
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
|
discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
|
||||||
discBytes, err := os.ReadFile(discFile)
|
discBytes, err := os.ReadFile(discFile)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
discState := strings.TrimSpace(string(discBytes))
|
discState := strings.TrimSpace(string(discBytes))
|
||||||
if discState == "DISCONNECTED" {
|
if discState == "DISCONNECTED" {
|
||||||
return "not attached"
|
return "not attached"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
137
jsonrpc.go
137
jsonrpc.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -163,6 +164,16 @@ func rpcPing() (string, error) {
|
|||||||
return "pong", nil
|
return "pong", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BootStorageTypeResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetBootStorageType() (*BootStorageTypeResponse, error) {
|
||||||
|
return &BootStorageTypeResponse{
|
||||||
|
Type: string(GetBootStorageType()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetDeviceID() (string, error) {
|
func rpcGetDeviceID() (string, error) {
|
||||||
return GetDeviceID(), nil
|
return GetDeviceID(), nil
|
||||||
}
|
}
|
||||||
@@ -388,6 +399,32 @@ func rpcSetCustomUpdateBaseURL(baseURL string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetUpdateDownloadProxy() (string, error) {
|
||||||
|
return config.UpdateDownloadProxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetUpdateDownloadProxy(proxy string) error {
|
||||||
|
proxy = strings.TrimSpace(proxy)
|
||||||
|
if proxy != "" {
|
||||||
|
parsed, err := url.Parse(proxy)
|
||||||
|
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return fmt.Errorf("invalid update download proxy")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("update download proxy must use http or https")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(proxy, "/") {
|
||||||
|
proxy += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.UpdateDownloadProxy = proxy
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
||||||
var err error
|
var err error
|
||||||
_, err = lvDispSetRotation(params.Rotation)
|
_, err = lvDispSetRotation(params.Rotation)
|
||||||
@@ -723,6 +760,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetUsbEnhancedDetection() (bool, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
return config.UsbEnhancedDetection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetUsbEnhancedDetection(enabled bool) error {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
if config.UsbEnhancedDetection == enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config.UsbEnhancedDetection = enabled
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gadget != nil {
|
||||||
|
checkUSBState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
return *config.UsbConfig, nil
|
return *config.UsbConfig, nil
|
||||||
@@ -762,6 +822,36 @@ func rpcResetConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetConfigRaw() (string, error) {
|
||||||
|
configLock.Lock()
|
||||||
|
defer configLock.Unlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetConfigRaw(configStr string) error {
|
||||||
|
var newConfig Config
|
||||||
|
if err := json.Unmarshal([]byte(configStr), &newConfig); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configLock.Lock()
|
||||||
|
config = &newConfig
|
||||||
|
configLock.Unlock()
|
||||||
|
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info().Msg("Configuration updated via raw JSON")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetActiveExtension() (string, error) {
|
func rpcGetActiveExtension() (string, error) {
|
||||||
return config.ActiveExtension, nil
|
return config.ActiveExtension, nil
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1284,42 @@ func rpcSetAutoMountSystemInfo(enabled bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetFirewallConfig() (FirewallConfig, error) {
|
||||||
|
LoadConfig()
|
||||||
|
if systemCfg, err := ReadFirewallConfigFromSystem(); err == nil && systemCfg != nil {
|
||||||
|
return *systemCfg, nil
|
||||||
|
}
|
||||||
|
if config.Firewall == nil {
|
||||||
|
return *defaultConfig.Firewall, nil
|
||||||
|
}
|
||||||
|
return *config.Firewall, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetFirewallConfig(firewallCfg FirewallConfig) error {
|
||||||
|
LoadConfig()
|
||||||
|
managedCfg := firewallCfg
|
||||||
|
managedCfg.PortForwards = filterManagedPortForwards(firewallCfg.PortForwards)
|
||||||
|
if err := ApplyFirewallConfig(&managedCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.Firewall = &managedCfg
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterManagedPortForwards(in []FirewallPortRule) []FirewallPortRule {
|
||||||
|
out := make([]FirewallPortRule, 0, len(in))
|
||||||
|
for _, r := range in {
|
||||||
|
if r.Managed != nil && !*r.Managed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func rpcConfirmOtherSession() (bool, error) {
|
func rpcConfirmOtherSession() (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1331,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getNetworkState": {Func: rpcGetNetworkState},
|
"getNetworkState": {Func: rpcGetNetworkState},
|
||||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||||
|
"setEthernetMacAddress": {Func: rpcSetEthernetMacAddress, Params: []string{"macAddress"}},
|
||||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||||
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
@@ -1237,6 +1364,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
|
||||||
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
|
||||||
|
"getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
|
||||||
|
"setUpdateDownloadProxy": {Func: rpcSetUpdateDownloadProxy, Params: []string{"proxy"}},
|
||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||||
@@ -1247,6 +1376,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||||
|
"getUsbEnhancedDetection": {Func: rpcGetUsbEnhancedDetection},
|
||||||
|
"setUsbEnhancedDetection": {Func: rpcSetUsbEnhancedDetection, Params: []string{"enabled"}},
|
||||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||||
@@ -1256,6 +1387,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"resetSDStorage": {Func: rpcResetSDStorage},
|
"resetSDStorage": {Func: rpcResetSDStorage},
|
||||||
"mountSDStorage": {Func: rpcMountSDStorage},
|
"mountSDStorage": {Func: rpcMountSDStorage},
|
||||||
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
"unmountSDStorage": {Func: rpcUnmountSDStorage},
|
||||||
|
"formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}},
|
||||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
||||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||||
@@ -1272,6 +1404,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"resetConfig": {Func: rpcResetConfig},
|
||||||
|
"getConfigRaw": {Func: rpcGetConfigRaw},
|
||||||
|
"setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
|
||||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||||
@@ -1345,4 +1479,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||||||
"getWireguardConfig": {Func: rpcGetWireguardConfig},
|
"getWireguardConfig": {Func: rpcGetWireguardConfig},
|
||||||
"getWireguardLog": {Func: rpcGetWireguardLog},
|
"getWireguardLog": {Func: rpcGetWireguardLog},
|
||||||
"getWireguardInfo": {Func: rpcGetWireguardInfo},
|
"getWireguardInfo": {Func: rpcGetWireguardInfo},
|
||||||
|
"getFirewallConfig": {Func: rpcGetFirewallConfig},
|
||||||
|
"setFirewallConfig": {Func: rpcSetFirewallConfig, Params: []string{"config"}},
|
||||||
|
"getBootStorageType": {Func: rpcGetBootStorageType},
|
||||||
}
|
}
|
||||||
|
|||||||
245
keys.go
Normal file
245
keys.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
evKey = 0x01
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyHoldResetDetector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pressAt map[uint16]time.Time
|
||||||
|
threshold time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
afterFunc func(d time.Duration, f func()) func() bool
|
||||||
|
stop map[uint16]func() bool
|
||||||
|
triggered bool
|
||||||
|
onTrigger func(code uint16, hold time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeyHoldResetDetector(threshold time.Duration, now func() time.Time, afterFunc func(d time.Duration, f func()) func() bool, onTrigger func(code uint16, hold time.Duration)) *keyHoldResetDetector {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
if afterFunc == nil {
|
||||||
|
afterFunc = func(d time.Duration, f func()) func() bool {
|
||||||
|
t := time.AfterFunc(d, f)
|
||||||
|
return t.Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &keyHoldResetDetector{
|
||||||
|
pressAt: map[uint16]time.Time{},
|
||||||
|
threshold: threshold,
|
||||||
|
now: now,
|
||||||
|
afterFunc: afterFunc,
|
||||||
|
stop: map[uint16]func() bool{},
|
||||||
|
onTrigger: onTrigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) close() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
for code, stop := range d.stop {
|
||||||
|
_ = stop()
|
||||||
|
delete(d.stop, code)
|
||||||
|
delete(d.pressAt, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) fire(code uint16) {
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.triggered {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.triggered = true
|
||||||
|
t0, ok := d.pressAt[code]
|
||||||
|
now := d.now()
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
hold := d.threshold
|
||||||
|
if ok {
|
||||||
|
hold = now.Sub(t0)
|
||||||
|
}
|
||||||
|
if d.onTrigger != nil {
|
||||||
|
d.onTrigger(code, hold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *keyHoldResetDetector) onEvent(typ uint16, code uint16, val int32) {
|
||||||
|
if typ != evKey {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val {
|
||||||
|
case 1, 2:
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.triggered {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := d.pressAt[code]; exists {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.pressAt[code] = d.now()
|
||||||
|
d.stop[code] = d.afterFunc(d.threshold, func() { d.fire(code) })
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
case 0:
|
||||||
|
d.mu.Lock()
|
||||||
|
if stop, ok := d.stop[code]; ok {
|
||||||
|
_ = stop()
|
||||||
|
delete(d.stop, code)
|
||||||
|
}
|
||||||
|
delete(d.pressAt, code)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultInputEventSize() int {
|
||||||
|
if strconv.IntSize == 64 {
|
||||||
|
return 24
|
||||||
|
}
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
func findInputEventDeviceByName(deviceName string) (string, error) {
|
||||||
|
entries, err := os.ReadDir("/sys/class/input")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasPrefix(e.Name(), "event") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
namePath := filepath.Join("/sys/class/input", e.Name(), "device/name")
|
||||||
|
b, err := os.ReadFile(namePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := strings.TrimSpace(string(b))
|
||||||
|
if n == deviceName {
|
||||||
|
return filepath.Join("/dev/input", e.Name()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("input device not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchAdcKeysLongPressReset(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := findInputEventDeviceByName("adc-keys")
|
||||||
|
if err != nil {
|
||||||
|
keysLogger.Warn().Err(err).Msg("adc-keys device not found")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(dev, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
keysLogger.Warn().Err(err).Str("device", dev).Msg("failed to open adc-keys device")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keysLogger.Info().Str("device", dev).Msg("watching adc-keys events")
|
||||||
|
var resetOnce sync.Once
|
||||||
|
detector := newKeyHoldResetDetector(
|
||||||
|
5*time.Second,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
func(code uint16, hold time.Duration) {
|
||||||
|
resetOnce.Do(func() {
|
||||||
|
keysLogger.Warn().Uint16("code", code).Dur("hold", hold).Msg("adc-keys long press detected, resetting config")
|
||||||
|
resetConfigFileAndReboot()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
eventSize := defaultInputEventSize()
|
||||||
|
buf := make([]byte, eventSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
detector.close()
|
||||||
|
_ = f.Close()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := io.ReadFull(f, buf)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, syscall.EINVAL) {
|
||||||
|
if eventSize == 24 {
|
||||||
|
eventSize = 16
|
||||||
|
} else {
|
||||||
|
eventSize = 24
|
||||||
|
}
|
||||||
|
buf = make([]byte, eventSize)
|
||||||
|
keysLogger.Info().Str("device", dev).Int("event_size", eventSize).Msg("adc-keys switched input_event size")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detector.close()
|
||||||
|
_ = f.Close()
|
||||||
|
keysLogger.Warn().Err(err).Str("device", dev).Msg("adc-keys read failed, reopening")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOff, codeOff, valOff := 16, 18, 20
|
||||||
|
if eventSize == 16 {
|
||||||
|
typeOff, codeOff, valOff = 8, 10, 12
|
||||||
|
}
|
||||||
|
|
||||||
|
typ := binary.LittleEndian.Uint16(buf[typeOff : typeOff+2])
|
||||||
|
code := binary.LittleEndian.Uint16(buf[codeOff : codeOff+2])
|
||||||
|
val := int32(binary.LittleEndian.Uint32(buf[valOff : valOff+4]))
|
||||||
|
|
||||||
|
detector.onEvent(typ, code, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetConfigFileAndReboot() {
|
||||||
|
resetFirewallForFactory()
|
||||||
|
|
||||||
|
if err := os.Remove(configPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
keysLogger.Error().Err(err).Str("path", configPath).Msg("failed to delete config file")
|
||||||
|
} else {
|
||||||
|
keysLogger.Warn().Str("path", configPath).Msg("config file deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
unix.Sync()
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
|
||||||
|
keysLogger.Error().Err(err).Msg("syscall reboot failed, trying /sbin/reboot")
|
||||||
|
_ = exec.Command("/sbin/reboot", "-f").Run()
|
||||||
|
_ = exec.Command("reboot", "-f").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
1
log.go
1
log.go
@@ -30,6 +30,7 @@ var (
|
|||||||
displayLogger = logging.GetSubsystemLogger("display")
|
displayLogger = logging.GetSubsystemLogger("display")
|
||||||
wolLogger = logging.GetSubsystemLogger("wol")
|
wolLogger = logging.GetSubsystemLogger("wol")
|
||||||
usbLogger = logging.GetSubsystemLogger("usb")
|
usbLogger = logging.GetSubsystemLogger("usb")
|
||||||
|
keysLogger = logging.GetSubsystemLogger("keys")
|
||||||
// external components
|
// external components
|
||||||
ginLogger = logging.GetSubsystemLogger("gin")
|
ginLogger = logging.GetSubsystemLogger("gin")
|
||||||
)
|
)
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -35,6 +35,7 @@ func Main() {
|
|||||||
Interface("app_version", appVersionLocal).
|
Interface("app_version", appVersionLocal).
|
||||||
Msg("starting KVM")
|
Msg("starting KVM")
|
||||||
|
|
||||||
|
go watchAdcKeysLongPressReset(appCtx)
|
||||||
go runWatchdog()
|
go runWatchdog()
|
||||||
go confirmCurrentSystem() //A/B system
|
go confirmCurrentSystem() //A/B system
|
||||||
if isNewEnoughSystem {
|
if isNewEnoughSystem {
|
||||||
@@ -57,6 +58,10 @@ func Main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ApplyFirewallConfig(config.Firewall); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to apply firewall config")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize time sync
|
// Initialize time sync
|
||||||
initTimeSync()
|
initTimeSync()
|
||||||
timeSync.Start()
|
timeSync.Start()
|
||||||
|
|||||||
20
network_mac.go
Normal file
20
network_mac.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ethernetMacAddressPath = "/userdata/ethaddr.txt"
|
||||||
|
|
||||||
|
func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
|
||||||
|
normalized, err := networkState.SetMACAddress(macAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
|
||||||
|
}
|
||||||
|
return networkState.RpcGetNetworkState(), nil
|
||||||
|
}
|
||||||
|
|
||||||
147
ota.go
147
ota.go
@@ -89,7 +89,7 @@ var UpdateGiteeSystemZipUrls = []string{
|
|||||||
|
|
||||||
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
||||||
|
|
||||||
var builtAppVersion = "0.1.1+dev"
|
var builtAppVersion = "0.1.2+dev"
|
||||||
|
|
||||||
var updateSource = "github"
|
var updateSource = "github"
|
||||||
var customUpdateBaseURL string
|
var customUpdateBaseURL string
|
||||||
@@ -672,13 +672,62 @@ func parseVersionTxt(s string) (appVersion string, systemVersion string, err err
|
|||||||
return appVersion, systemVersion, nil
|
return appVersion, systemVersion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
|
func shouldProxyUpdateDownloadURL(u *url.URL) bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "github.com" || host == "api.github.com" || host == "codeload.github.com" || host == "raw.githubusercontent.com" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") || strings.HasSuffix(host, ".githubassets.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdateDownloadProxyPrefix(rawURL string) string {
|
||||||
|
if config == nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
proxy := strings.TrimSpace(config.UpdateDownloadProxy)
|
||||||
|
if proxy == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
proxy = strings.TrimRight(proxy, "/") + "/"
|
||||||
|
if strings.HasPrefix(rawURL, proxy) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed == nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
if !shouldProxyUpdateDownloadURL(parsed) {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return proxy + rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(
|
||||||
|
ctx context.Context,
|
||||||
|
path string,
|
||||||
|
url string,
|
||||||
|
downloadProgress *float32,
|
||||||
|
downloadSpeedBps *float32,
|
||||||
|
) error {
|
||||||
//if _, err := os.Stat(path); err == nil {
|
//if _, err := os.Stat(path); err == nil {
|
||||||
// if err := os.Remove(path); err != nil {
|
// if err := os.Remove(path); err != nil {
|
||||||
// return fmt.Errorf("error removing existing file: %w", err)
|
// return fmt.Errorf("error removing existing file: %w", err)
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
otaLogger.Info().Str("path", path).Str("url", url).Msg("downloading file")
|
finalURL := applyUpdateDownloadProxyPrefix(url)
|
||||||
|
otaLogger.Info().Str("path", path).Str("url", finalURL).Msg("downloading file")
|
||||||
|
|
||||||
unverifiedPath := path + ".unverified"
|
unverifiedPath := path + ".unverified"
|
||||||
if _, err := os.Stat(unverifiedPath); err == nil {
|
if _, err := os.Stat(unverifiedPath); err == nil {
|
||||||
@@ -693,7 +742,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating request: %w", err)
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -726,6 +775,19 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
var lastProgressBytes int64
|
var lastProgressBytes int64
|
||||||
lastProgressAt := time.Now()
|
lastProgressAt := time.Now()
|
||||||
lastReportedProgress := float32(0)
|
lastReportedProgress := float32(0)
|
||||||
|
lastSpeedAt := time.Now()
|
||||||
|
var lastSpeedBytes int64
|
||||||
|
|
||||||
|
if downloadProgress != nil {
|
||||||
|
*downloadProgress = 0
|
||||||
|
}
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
if downloadProgress != nil || downloadSpeedBps != nil {
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
@@ -738,20 +800,40 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
if ew != nil {
|
if ew != nil {
|
||||||
return fmt.Errorf("error writing to file: %w", ew)
|
return fmt.Errorf("error writing to file: %w", ew)
|
||||||
}
|
}
|
||||||
if hasKnownSize && downloadProgress != nil {
|
now := time.Now()
|
||||||
progress := float32(written) / float32(totalSize)
|
speedUpdated := false
|
||||||
if progress-lastReportedProgress >= 0.001 || time.Since(lastProgressAt) >= 1*time.Second {
|
progressUpdated := false
|
||||||
lastReportedProgress = progress
|
|
||||||
*downloadProgress = lastReportedProgress
|
if downloadSpeedBps != nil {
|
||||||
triggerOTAStateUpdate()
|
dt := now.Sub(lastSpeedAt)
|
||||||
lastProgressAt = time.Now()
|
if dt >= 1*time.Second {
|
||||||
|
seconds := float32(dt.Seconds())
|
||||||
|
if seconds <= 0 {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
} else {
|
||||||
|
*downloadSpeedBps = float32(written-lastSpeedBytes) / seconds
|
||||||
|
}
|
||||||
|
lastSpeedAt = now
|
||||||
|
lastSpeedBytes = written
|
||||||
|
speedUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasKnownSize && downloadProgress != nil {
|
||||||
|
progress := float32(written) / float32(totalSize)
|
||||||
|
if progress-lastReportedProgress >= 0.001 || now.Sub(lastProgressAt) >= 1*time.Second {
|
||||||
|
lastReportedProgress = progress
|
||||||
|
*downloadProgress = lastReportedProgress
|
||||||
|
lastProgressAt = now
|
||||||
|
progressUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !hasKnownSize && downloadProgress != nil {
|
if !hasKnownSize && downloadProgress != nil {
|
||||||
if *downloadProgress <= 0 {
|
if *downloadProgress <= 0 {
|
||||||
*downloadProgress = 0.01
|
*downloadProgress = 0.01
|
||||||
triggerOTAStateUpdate()
|
|
||||||
lastProgressBytes = written
|
lastProgressBytes = written
|
||||||
|
progressUpdated = true
|
||||||
} else if written-lastProgressBytes >= 1024*1024 {
|
} else if written-lastProgressBytes >= 1024*1024 {
|
||||||
next := *downloadProgress + 0.01
|
next := *downloadProgress + 0.01
|
||||||
if next > 0.99 {
|
if next > 0.99 {
|
||||||
@@ -759,11 +841,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
}
|
}
|
||||||
if next-*downloadProgress >= 0.01 {
|
if next-*downloadProgress >= 0.01 {
|
||||||
*downloadProgress = next
|
*downloadProgress = next
|
||||||
triggerOTAStateUpdate()
|
|
||||||
lastProgressBytes = written
|
lastProgressBytes = written
|
||||||
|
progressUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if speedUpdated || progressUpdated {
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if er != nil {
|
if er != nil {
|
||||||
if er == io.EOF {
|
if er == io.EOF {
|
||||||
@@ -779,6 +865,14 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||||||
|
|
||||||
if downloadProgress != nil && !hasKnownSize {
|
if downloadProgress != nil && !hasKnownSize {
|
||||||
*downloadProgress = 1
|
*downloadProgress = 1
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
triggerOTAStateUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadSpeedBps != nil && hasKnownSize {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +907,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
zipURL string,
|
zipURL string,
|
||||||
outputTarPath string,
|
outputTarPath string,
|
||||||
downloadProgress *float32,
|
downloadProgress *float32,
|
||||||
|
downloadSpeedBps *float32,
|
||||||
verificationProgress *float32,
|
verificationProgress *float32,
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) error {
|
||||||
@@ -846,10 +941,15 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
if downloadProgress != nil {
|
if downloadProgress != nil {
|
||||||
*downloadProgress = 0
|
*downloadProgress = 0
|
||||||
|
}
|
||||||
|
if downloadSpeedBps != nil {
|
||||||
|
*downloadSpeedBps = 0
|
||||||
|
}
|
||||||
|
if downloadProgress != nil || downloadSpeedBps != nil {
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress); err != nil {
|
if err := downloadFile(ctx, zipPath, zipURL, downloadProgress, downloadSpeedBps); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
zipUnverifiedPath := zipPath + ".unverified"
|
zipUnverifiedPath := zipPath + ".unverified"
|
||||||
@@ -1084,8 +1184,10 @@ type OTAState struct {
|
|||||||
AppUpdatePending bool `json:"appUpdatePending"`
|
AppUpdatePending bool `json:"appUpdatePending"`
|
||||||
SystemUpdatePending bool `json:"systemUpdatePending"`
|
SystemUpdatePending bool `json:"systemUpdatePending"`
|
||||||
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
|
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
|
||||||
|
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
|
||||||
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
||||||
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
|
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
|
||||||
|
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
|
||||||
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
||||||
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
||||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||||
@@ -1177,7 +1279,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
Str("remote", remote.AppVersion).
|
Str("remote", remote.AppVersion).
|
||||||
Msg("App update available")
|
Msg("App update available")
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/picokvm/bin/kvm_app", remote.AppUrl, &otaState.AppDownloadProgress)
|
err := downloadFile(
|
||||||
|
ctx,
|
||||||
|
"/userdata/picokvm/bin/kvm_app",
|
||||||
|
remote.AppUrl,
|
||||||
|
&otaState.AppDownloadProgress,
|
||||||
|
&otaState.AppDownloadSpeedBps,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
|
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading app update")
|
scopedLogger.Error().Err(err).Msg("Error downloading app update")
|
||||||
@@ -1227,6 +1335,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
remote.SystemUrl,
|
remote.SystemUrl,
|
||||||
systemTarPath,
|
systemTarPath,
|
||||||
&otaState.SystemDownloadProgress,
|
&otaState.SystemDownloadProgress,
|
||||||
|
&otaState.SystemDownloadSpeedBps,
|
||||||
&otaState.SystemVerificationProgress,
|
&otaState.SystemVerificationProgress,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
@@ -1238,7 +1347,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
systemZipPath := "/userdata/picokvm/update_system.zip"
|
systemZipPath := "/userdata/picokvm/update_system.zip"
|
||||||
err := downloadFile(ctx, systemZipPath, remote.SystemUrl, &otaState.SystemDownloadProgress)
|
err := downloadFile(
|
||||||
|
ctx,
|
||||||
|
systemZipPath,
|
||||||
|
remote.SystemUrl,
|
||||||
|
&otaState.SystemDownloadProgress,
|
||||||
|
&otaState.SystemDownloadSpeedBps,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading system update")
|
scopedLogger.Error().Err(err).Msg("Error downloading system update")
|
||||||
|
|||||||
295
ui/package-lock.json
generated
295
ui/package-lock.json
generated
@@ -1790,9 +1790,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1803,9 +1803,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1816,9 +1816,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1829,9 +1829,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1842,9 +1842,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1855,9 +1855,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1868,9 +1868,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1881,9 +1881,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1894,9 +1894,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1907,9 +1907,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1919,10 +1919,10 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1932,10 +1932,36 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==",
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1946,9 +1972,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1959,9 +1985,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1972,9 +1998,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1985,9 +2011,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1998,9 +2024,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2010,10 +2036,36 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2024,9 +2076,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2036,10 +2088,23 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2926,9 +2991,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/hoist-non-react-statics": {
|
"node_modules/@types/hoist-non-react-statics": {
|
||||||
@@ -3186,24 +3251,37 @@
|
|||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <5.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -3368,9 +3446,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -6780,9 +6858,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
@@ -8436,12 +8514,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.41.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.7"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
@@ -8451,26 +8529,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.41.0",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.41.0",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.41.0",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.41.0",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.41.0",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.41.0",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.41.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.41.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.41.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.41.0",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loongarch64-gnu": "4.41.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.41.0",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.41.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.41.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.41.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.41.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.41.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.41.0",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.41.0",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.41.0",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9104,9 +9187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.7",
|
"version": "7.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ export function FileUploader({
|
|||||||
onBack,
|
onBack,
|
||||||
incompleteFileName,
|
incompleteFileName,
|
||||||
media,
|
media,
|
||||||
|
accept,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
incompleteFileName?: string;
|
incompleteFileName?: string;
|
||||||
media?: string;
|
media?: string;
|
||||||
|
accept?: string;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const { $at }= useReactAt();
|
const { $at }= useReactAt();
|
||||||
@@ -39,9 +41,25 @@ export function FileUploader({
|
|||||||
|
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
|
|
||||||
|
const validateSelectedFile = (file: File) => {
|
||||||
|
if (!accept) return null;
|
||||||
|
const allowedExts = accept
|
||||||
|
.split(",")
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => s.startsWith("."));
|
||||||
|
if (allowedExts.length === 0) return null;
|
||||||
|
const lowerName = file.name.toLowerCase();
|
||||||
|
if (allowedExts.some(ext => lowerName.endsWith(ext))) return null;
|
||||||
|
return $at("Only {{types}} files are supported").replace(
|
||||||
|
"{{types}}",
|
||||||
|
allowedExts.join(", "),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = rtcDataChannelRef.current;
|
const ref = rtcDataChannelRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -262,6 +280,13 @@ export function FileUploader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
|
const validationError = validateSelectedFile(file);
|
||||||
|
if (validationError) {
|
||||||
|
setFileError(validationError);
|
||||||
|
setUploadState("idle");
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
|
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
|
||||||
setUploadedFileName(file.name);
|
setUploadedFileName(file.name);
|
||||||
setUploadedFileSize(file.size);
|
setUploadedFileSize(file.size);
|
||||||
@@ -338,7 +363,7 @@ export function FileUploader({
|
|||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (uploadState === "idle") {
|
if (uploadState === "idle") {
|
||||||
document.getElementById("file-upload")?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="block select-none"
|
className="block select-none"
|
||||||
@@ -369,7 +394,7 @@ export function FileUploader({
|
|||||||
<span>{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}</span>
|
<span>{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: $at("Click here to upload a new image")
|
: $at("Click here to upload")
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{/*<p className="text-xs leading-none text-slate-700 dark:text-slate-300">*/}
|
{/*<p className="text-xs leading-none text-slate-700 dark:text-slate-300">*/}
|
||||||
@@ -435,10 +460,11 @@ export function FileUploader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
type="file"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept={accept}
|
||||||
/>
|
/>
|
||||||
{fileError && (
|
{fileError && (
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
|
||||||
@@ -466,4 +492,4 @@ export function FileUploader({
|
|||||||
{isMobile&&<div className="h-[30px]"></div>}
|
{isMobile&&<div className="h-[30px]"></div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -678,6 +678,7 @@ export interface UpdateState {
|
|||||||
appUpdatePending: boolean;
|
appUpdatePending: boolean;
|
||||||
|
|
||||||
appDownloadProgress: number;
|
appDownloadProgress: number;
|
||||||
|
appDownloadSpeedBps: number;
|
||||||
appDownloadFinishedAt: string | null;
|
appDownloadFinishedAt: string | null;
|
||||||
|
|
||||||
appVerificationProgress: number;
|
appVerificationProgress: number;
|
||||||
@@ -690,6 +691,7 @@ export interface UpdateState {
|
|||||||
systemUpdatePending: boolean;
|
systemUpdatePending: boolean;
|
||||||
|
|
||||||
systemDownloadProgress: number;
|
systemDownloadProgress: number;
|
||||||
|
systemDownloadSpeedBps: number;
|
||||||
systemDownloadFinishedAt: string | null;
|
systemDownloadFinishedAt: string | null;
|
||||||
|
|
||||||
systemVerificationProgress: number;
|
systemVerificationProgress: number;
|
||||||
@@ -732,10 +734,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||||||
appUpdatePending: false,
|
appUpdatePending: false,
|
||||||
systemUpdatePending: false,
|
systemUpdatePending: false,
|
||||||
appDownloadProgress: 0,
|
appDownloadProgress: 0,
|
||||||
|
appDownloadSpeedBps: 0,
|
||||||
appDownloadFinishedAt: null,
|
appDownloadFinishedAt: null,
|
||||||
appVerificationProgress: 0,
|
appVerificationProgress: 0,
|
||||||
appVerifiedAt: null,
|
appVerifiedAt: null,
|
||||||
systemDownloadProgress: 0,
|
systemDownloadProgress: 0,
|
||||||
|
systemDownloadSpeedBps: 0,
|
||||||
systemDownloadFinishedAt: null,
|
systemDownloadFinishedAt: null,
|
||||||
systemVerificationProgress: 0,
|
systemVerificationProgress: 0,
|
||||||
systemVerifiedAt: null,
|
systemVerifiedAt: null,
|
||||||
@@ -1143,3 +1147,15 @@ export interface LogEntry {
|
|||||||
message: string;
|
message: string;
|
||||||
originalArgs: any[];
|
originalArgs: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BootStorageType = 'emmc' | 'sd' | 'unknown';
|
||||||
|
|
||||||
|
interface BootStorageState {
|
||||||
|
bootStorageType: BootStorageType;
|
||||||
|
setBootStorageType: (type: BootStorageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBootStorageStore = create<BootStorageState>(set => ({
|
||||||
|
bootStorageType: 'unknown',
|
||||||
|
setBootStorageType: type => set({ bootStorageType: type }),
|
||||||
|
}));
|
||||||
|
|||||||
30
ui/src/hooks/useBootStorage.ts
Normal file
30
ui/src/hooks/useBootStorage.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useJsonRpc } from "./useJsonRpc";
|
||||||
|
import { useBootStorageStore, BootStorageType } from "./stores";
|
||||||
|
|
||||||
|
export const useBootStorageType = () => {
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
const bootStorageType = useBootStorageStore(state => state.bootStorageType);
|
||||||
|
const setBootStorageType = useBootStorageStore(state => state.setBootStorageType);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bootStorageType !== "unknown") {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send("getBootStorageType", {}, res => {
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in res) {
|
||||||
|
console.error("Failed to get boot storage type:", res.error);
|
||||||
|
setBootStorageType("unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { type } = res.result as { type: BootStorageType };
|
||||||
|
setBootStorageType(type);
|
||||||
|
});
|
||||||
|
}, [send, bootStorageType, setBootStorageType]);
|
||||||
|
|
||||||
|
return { bootStorageType, loading };
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ import { useVpnStore, useLocalAuthModalStore } from "@/hooks/stores";
|
|||||||
import { LogDialog } from "@components/LogDialog";
|
import { LogDialog } from "@components/LogDialog";
|
||||||
import { Dialog } from "@/layout/components_setting/access/auth";
|
import { Dialog } from "@/layout/components_setting/access/auth";
|
||||||
import AutoHeight from "@components/AutoHeight";
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
import FirewallSettings from "./FirewallSettings";
|
||||||
|
|
||||||
export interface TailScaleResponse {
|
export interface TailScaleResponse {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -988,6 +989,9 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FirewallSettings />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
940
ui/src/layout/components_setting/access/FirewallSettings.tsx
Normal file
940
ui/src/layout/components_setting/access/FirewallSettings.tsx
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button as AntdButton, Checkbox, Input, Modal, Select } from "antd";
|
||||||
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
import { SettingsSectionHeader } from "@components/Settings/SettingsSectionHeader";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||||
|
|
||||||
|
type FirewallChain = "input" | "output" | "forward";
|
||||||
|
type FirewallAction = "accept" | "drop" | "reject";
|
||||||
|
|
||||||
|
export interface FirewallConfig {
|
||||||
|
base: {
|
||||||
|
inputPolicy: FirewallAction;
|
||||||
|
outputPolicy: FirewallAction;
|
||||||
|
forwardPolicy: FirewallAction;
|
||||||
|
};
|
||||||
|
rules: FirewallRule[];
|
||||||
|
portForwards: FirewallPortRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallRule {
|
||||||
|
chain: FirewallChain;
|
||||||
|
sourceIP: string;
|
||||||
|
sourcePort?: number | null;
|
||||||
|
protocols: string[];
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort?: number | null;
|
||||||
|
action: FirewallAction;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallPortRule {
|
||||||
|
chain?: "output" | "prerouting" | "prerouting_redirect";
|
||||||
|
managed?: boolean;
|
||||||
|
sourcePort: number;
|
||||||
|
protocols: string[];
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFirewallConfig: FirewallConfig = {
|
||||||
|
base: { inputPolicy: "accept", outputPolicy: "accept", forwardPolicy: "accept" },
|
||||||
|
rules: [],
|
||||||
|
portForwards: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionOptions: { value: FirewallAction; label: string }[] = [
|
||||||
|
{ value: "accept", label: "Accept" },
|
||||||
|
{ value: "drop", label: "Drop" },
|
||||||
|
{ value: "reject", label: "Reject" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chainOptions: { value: FirewallChain; label: string }[] = [
|
||||||
|
{ value: "input", label: "Input" },
|
||||||
|
{ value: "output", label: "Output" },
|
||||||
|
{ value: "forward", label: "Forward" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commProtocolOptions = [
|
||||||
|
{ key: "any", label: "Any" },
|
||||||
|
{ key: "tcp", label: "TCP" },
|
||||||
|
{ key: "udp", label: "UDP" },
|
||||||
|
{ key: "icmp", label: "ICMP" },
|
||||||
|
{ key: "igmp", label: "IGMP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const portForwardProtocolOptions = [
|
||||||
|
{ key: "tcp", label: "TCP" },
|
||||||
|
{ key: "udp", label: "UDP" },
|
||||||
|
{ key: "sctp", label: "SCTP" },
|
||||||
|
{ key: "dccp", label: "DCCP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatProtocols(protocols: string[]) {
|
||||||
|
if (!protocols?.length) return "-";
|
||||||
|
if (protocols.includes("any")) return "Any";
|
||||||
|
return protocols.map(p => p.toUpperCase()).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: FirewallAction) {
|
||||||
|
return actionOptions.find(o => o.value === action)?.label ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainLabel(chain: FirewallChain) {
|
||||||
|
return chainOptions.find(o => o.value === chain)?.label ?? chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portForwardChainLabel(chain: FirewallPortRule["chain"]) {
|
||||||
|
switch (chain ?? "prerouting") {
|
||||||
|
case "output":
|
||||||
|
return "OUTPUT";
|
||||||
|
case "prerouting_redirect":
|
||||||
|
return "PREROUTING_REDIRECT";
|
||||||
|
default:
|
||||||
|
return "PREROUTING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEndpoint(ip: string, port: number | null | undefined, anyText: string) {
|
||||||
|
const t = (ip || "").trim();
|
||||||
|
if (!t && (port === null || port === undefined)) return anyText;
|
||||||
|
if (!t && port !== null && port !== undefined) return `${anyText}:${port}`;
|
||||||
|
if (t && (port === null || port === undefined)) return t;
|
||||||
|
return `${t}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(v: string) {
|
||||||
|
const t = v.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
const n = Number(t);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
if (n < 1 || n > 65535) return null;
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleProtocols(list: string[]) {
|
||||||
|
if (list.includes("any")) return ["any"];
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirewallSettings() {
|
||||||
|
const { $at } = useReactAt();
|
||||||
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"base" | "rules" | "portForwards">(
|
||||||
|
"base",
|
||||||
|
);
|
||||||
|
const [appliedConfig, setAppliedConfig] = useState<FirewallConfig>(defaultFirewallConfig);
|
||||||
|
const [baseDraft, setBaseDraft] = useState<FirewallConfig["base"]>(
|
||||||
|
defaultFirewallConfig.base,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [showBaseSubmitConfirm, setShowBaseSubmitConfirm] = useState(false);
|
||||||
|
|
||||||
|
const [selectedRuleRows, setSelectedRuleRows] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedPortForwardRows, setSelectedPortForwardRows] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||||
|
const [ruleEditingIndex, setRuleEditingIndex] = useState<number | null>(null);
|
||||||
|
const [ruleDraft, setRuleDraft] = useState<FirewallRule>({
|
||||||
|
chain: "input",
|
||||||
|
sourceIP: "",
|
||||||
|
sourcePort: null,
|
||||||
|
protocols: ["any"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: null,
|
||||||
|
action: "accept",
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
const [ruleSourcePortText, setRuleSourcePortText] = useState<string>("");
|
||||||
|
const [ruleDestinationPortText, setRuleDestinationPortText] = useState<string>("");
|
||||||
|
|
||||||
|
const [pfModalOpen, setPfModalOpen] = useState(false);
|
||||||
|
const [pfEditingIndex, setPfEditingIndex] = useState<number | null>(null);
|
||||||
|
const [pfDraft, setPfDraft] = useState<FirewallPortRule>({
|
||||||
|
chain: "prerouting",
|
||||||
|
sourcePort: 1,
|
||||||
|
protocols: ["tcp"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: 1,
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
const [pfSourcePortText, setPfSourcePortText] = useState<string>("1");
|
||||||
|
const [pfDestinationPortText, setPfDestinationPortText] = useState<string>("1");
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
send("getFirewallConfig", {}, resp => {
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`${$at("Failed to get firewall config")}: ${resp.error.data || resp.error.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cfg = resp.result as FirewallConfig;
|
||||||
|
setAppliedConfig(cfg);
|
||||||
|
setBaseDraft(cfg.base);
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
});
|
||||||
|
}, [send, $at]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const hasBaseChanges = useMemo(() => {
|
||||||
|
return JSON.stringify(appliedConfig.base) !== JSON.stringify(baseDraft);
|
||||||
|
}, [appliedConfig.base, baseDraft]);
|
||||||
|
|
||||||
|
const applyFirewallConfig = useCallback(
|
||||||
|
(
|
||||||
|
nextConfig: FirewallConfig,
|
||||||
|
opts?: { onSuccess?: () => void; successText?: string },
|
||||||
|
) => {
|
||||||
|
setApplying(true);
|
||||||
|
send("setFirewallConfig", { config: nextConfig }, resp => {
|
||||||
|
setApplying(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`${$at("Failed to apply firewall config")}: ${resp.error.data || resp.error.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppliedConfig(nextConfig);
|
||||||
|
if (opts?.successText) notifications.success(opts.successText);
|
||||||
|
if (opts?.onSuccess) opts.onSuccess();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send, $at],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBaseSubmit = useCallback(() => {
|
||||||
|
const nextConfig: FirewallConfig = { ...appliedConfig, base: baseDraft };
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowBaseSubmitConfirm(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [appliedConfig, baseDraft, applyFirewallConfig, $at]);
|
||||||
|
|
||||||
|
const requestBaseSubmit = useCallback(() => {
|
||||||
|
if (!hasBaseChanges) return;
|
||||||
|
setShowBaseSubmitConfirm(true);
|
||||||
|
}, [hasBaseChanges]);
|
||||||
|
|
||||||
|
const openAddRule = () => {
|
||||||
|
setRuleEditingIndex(null);
|
||||||
|
setRuleDraft({
|
||||||
|
chain: "input",
|
||||||
|
sourceIP: "",
|
||||||
|
sourcePort: null,
|
||||||
|
protocols: ["any"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: null,
|
||||||
|
action: "accept",
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
setRuleSourcePortText("");
|
||||||
|
setRuleDestinationPortText("");
|
||||||
|
setRuleModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditRule = (idx: number) => {
|
||||||
|
const current = appliedConfig.rules[idx];
|
||||||
|
if (!current) return;
|
||||||
|
setRuleEditingIndex(idx);
|
||||||
|
setRuleDraft({ ...current });
|
||||||
|
setRuleSourcePortText(current.sourcePort ? String(current.sourcePort) : "");
|
||||||
|
setRuleDestinationPortText(current.destinationPort ? String(current.destinationPort) : "");
|
||||||
|
setRuleModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRuleDraft = () => {
|
||||||
|
const next: FirewallRule = {
|
||||||
|
...ruleDraft,
|
||||||
|
protocols: normalizeRuleProtocols(ruleDraft.protocols),
|
||||||
|
sourcePort: normalizePort(ruleSourcePortText),
|
||||||
|
destinationPort: normalizePort(ruleDestinationPortText),
|
||||||
|
sourceIP: ruleDraft.sourceIP.trim(),
|
||||||
|
destinationIP: ruleDraft.destinationIP.trim(),
|
||||||
|
comment: ruleDraft.comment.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!next.protocols.length) {
|
||||||
|
notifications.error($at("Please select protocol"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.chain || !next.action) {
|
||||||
|
notifications.error($at("Missing required fields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = [...appliedConfig.rules];
|
||||||
|
if (ruleEditingIndex === null) {
|
||||||
|
rules.push(next);
|
||||||
|
} else {
|
||||||
|
rules[ruleEditingIndex] = next;
|
||||||
|
}
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRuleModalOpen(false);
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedRules = () => {
|
||||||
|
const idxs = [...selectedRuleRows.values()].sort((a, b) => a - b);
|
||||||
|
if (!idxs.length) return;
|
||||||
|
const nextRules = appliedConfig.rules.filter((_, i) => !selectedRuleRows.has(i));
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
rules: nextRules,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedRuleRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddPortForward = () => {
|
||||||
|
setPfEditingIndex(null);
|
||||||
|
setPfDraft({
|
||||||
|
chain: "prerouting",
|
||||||
|
sourcePort: 1,
|
||||||
|
protocols: ["tcp"],
|
||||||
|
destinationIP: "",
|
||||||
|
destinationPort: 1,
|
||||||
|
comment: "",
|
||||||
|
});
|
||||||
|
setPfSourcePortText("1");
|
||||||
|
setPfDestinationPortText("1");
|
||||||
|
setPfModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditPortForward = (idx: number) => {
|
||||||
|
const current = appliedConfig.portForwards[idx];
|
||||||
|
if (!current) return;
|
||||||
|
if (current.managed === false) return;
|
||||||
|
setPfEditingIndex(idx);
|
||||||
|
const inferredChain =
|
||||||
|
current.chain ??
|
||||||
|
(current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
|
||||||
|
? "output"
|
||||||
|
: "prerouting");
|
||||||
|
setPfDraft({
|
||||||
|
...current,
|
||||||
|
chain: inferredChain,
|
||||||
|
destinationIP:
|
||||||
|
inferredChain === "output" || inferredChain === "prerouting_redirect"
|
||||||
|
? "0.0.0.0"
|
||||||
|
: current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
|
||||||
|
? ""
|
||||||
|
: current.destinationIP,
|
||||||
|
});
|
||||||
|
setPfSourcePortText(String(current.sourcePort));
|
||||||
|
setPfDestinationPortText(String(current.destinationPort));
|
||||||
|
setPfModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePortForwardDraft = () => {
|
||||||
|
const srcPort = normalizePort(pfSourcePortText);
|
||||||
|
const dstPort = normalizePort(pfDestinationPortText);
|
||||||
|
if (!srcPort || !dstPort) {
|
||||||
|
notifications.error($at("Invalid port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next: FirewallPortRule = {
|
||||||
|
...pfDraft,
|
||||||
|
sourcePort: srcPort,
|
||||||
|
destinationPort: dstPort,
|
||||||
|
destinationIP:
|
||||||
|
(pfDraft.chain ?? "prerouting") === "output" ||
|
||||||
|
(pfDraft.chain ?? "prerouting") === "prerouting_redirect"
|
||||||
|
? "0.0.0.0"
|
||||||
|
: pfDraft.destinationIP.trim(),
|
||||||
|
protocols: normalizeRuleProtocols(pfDraft.protocols).filter(p => p !== "any"),
|
||||||
|
comment: pfDraft.comment.trim(),
|
||||||
|
};
|
||||||
|
const pfChain = next.chain ?? "prerouting";
|
||||||
|
if (pfChain === "prerouting" && ["0.0.0.0", "127.0.0.1"].includes(next.destinationIP.trim())) {
|
||||||
|
notifications.error($at("For PREROUTING, Destination IP must be a real host IP"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pfChain === "prerouting" && !next.destinationIP) {
|
||||||
|
notifications.error($at("Destination IP is required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!next.protocols.length) {
|
||||||
|
notifications.error($at("Please select protocol"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...appliedConfig.portForwards];
|
||||||
|
if (pfEditingIndex === null) {
|
||||||
|
items.push(next);
|
||||||
|
} else {
|
||||||
|
items[pfEditingIndex] = next;
|
||||||
|
}
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
portForwards: items,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPfModalOpen(false);
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedPortForwards = () => {
|
||||||
|
const idxs = [...selectedPortForwardRows.values()].sort((a, b) => a - b);
|
||||||
|
if (!idxs.length) return;
|
||||||
|
const nextItems = appliedConfig.portForwards.filter((r, i) => {
|
||||||
|
if (!selectedPortForwardRows.has(i)) return true;
|
||||||
|
return r.managed === false;
|
||||||
|
});
|
||||||
|
const nextConfig: FirewallConfig = {
|
||||||
|
...appliedConfig,
|
||||||
|
portForwards: nextItems,
|
||||||
|
};
|
||||||
|
applyFirewallConfig(nextConfig, {
|
||||||
|
successText: $at("Firewall config applied"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedPortForwardRows(new Set());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("Firewall")}
|
||||||
|
badge="Experimental"
|
||||||
|
description={$at("Manage the firewall rules of the device")}
|
||||||
|
>
|
||||||
|
</SettingsItem>
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="flex min-w-max">
|
||||||
|
{[
|
||||||
|
{ id: "base", label: $at("Basic") },
|
||||||
|
{ id: "rules", label: $at("Communication Rules") },
|
||||||
|
{ id: "portForwards", label: $at("Port Forwarding") },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={`
|
||||||
|
flex-1 min-w-[120px] px-6 py-3 text-sm font-medium transition-all duration-200 border-y border-r first:border-l first:rounded-l-lg last:rounded-r-lg flex items-center justify-center gap-2
|
||||||
|
${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "!bg-[rgba(22,152,217,1)] dark:!bg-[rgba(45,106,229,1))] !text-white border-[rgba(22,152,217,1)] dark:border-[rgba(45,106,229,1)]"
|
||||||
|
: "bg-transparent text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-700 hover:border-[rgba(22,152,217,1)] dark:hover:border-[rgba(45,106,229,1)] hover:text-[rgba(22,152,217,1)] dark:hover:text-[rgba(45,106,229,1)]"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{activeTab === "base" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[64px_1fr] gap-y-3 items-center">
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Input")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.inputPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
inputPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Output")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.outputPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
outputPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{$at("Forward")}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[28%] !h-[36px]"}
|
||||||
|
value={baseDraft.forwardPolicy}
|
||||||
|
onChange={v =>
|
||||||
|
setBaseDraft({
|
||||||
|
...baseDraft,
|
||||||
|
forwardPolicy: v as FirewallAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton onClick={fetchConfig} loading={loading}>
|
||||||
|
{$at("Refresh")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={requestBaseSubmit}
|
||||||
|
loading={applying}
|
||||||
|
disabled={!hasBaseChanges}
|
||||||
|
>
|
||||||
|
{$at("Submit")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "rules" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={openAddRule}
|
||||||
|
>
|
||||||
|
{$at("Add")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton danger onClick={deleteSelectedRules} disabled={!selectedRuleRows.size}>
|
||||||
|
{$at("Delete")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="min-w-max w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 p-2 text-center font-medium" />
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Source")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Action")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Description")}</th>
|
||||||
|
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{appliedConfig.rules.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-6 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("No rules available")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
appliedConfig.rules.map((r, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className="border-t border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRuleRows.has(idx)}
|
||||||
|
onChange={e => {
|
||||||
|
const next = new Set(selectedRuleRows);
|
||||||
|
if (e.target.checked) next.add(idx);
|
||||||
|
else next.delete(idx);
|
||||||
|
setSelectedRuleRows(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{chainLabel(r.chain)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.sourceIP, r.sourcePort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{actionLabel(r.action)}</td>
|
||||||
|
<td className="p-2 text-center">{r.comment || "-"}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<AntdButton size="small" onClick={() => openEditRule(idx)}>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "portForwards" && (
|
||||||
|
<AutoHeight>
|
||||||
|
<GridCard>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
onClick={openAddPortForward}
|
||||||
|
>
|
||||||
|
{$at("Add")}
|
||||||
|
</AntdButton>
|
||||||
|
<AntdButton
|
||||||
|
danger
|
||||||
|
onClick={deleteSelectedPortForwards}
|
||||||
|
disabled={!selectedPortForwardRows.size}
|
||||||
|
>
|
||||||
|
{$at("Delete")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="min-w-max w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-50 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 p-2 text-center font-medium" />
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Chain")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Source")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Protocol")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Destination")}</th>
|
||||||
|
<th className="p-2 text-center font-medium">{$at("Description")}</th>
|
||||||
|
<th className="w-20 p-2 text-center font-medium">{$at("Operation")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{appliedConfig.portForwards.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-6 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
{$at("No data available")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
appliedConfig.portForwards.map((r, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className="border-t border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedPortForwardRows.has(idx)}
|
||||||
|
disabled={r.managed === false}
|
||||||
|
onChange={e => {
|
||||||
|
const next = new Set(selectedPortForwardRows);
|
||||||
|
if (e.target.checked) next.add(idx);
|
||||||
|
else next.delete(idx);
|
||||||
|
setSelectedPortForwardRows(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{portForwardChainLabel(r.chain)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint("", r.sourcePort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{formatProtocols(r.protocols)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-center">{r.comment || "-"}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
<AntdButton size="small" disabled={r.managed === false} onClick={() => openEditPortForward(idx)}>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</AutoHeight>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={ruleModalOpen}
|
||||||
|
onCancel={() => setRuleModalOpen(false)}
|
||||||
|
onOk={saveRuleDraft}
|
||||||
|
confirmLoading={applying}
|
||||||
|
title={$at(ruleEditingIndex === null ? "Add Rule" : "Edit Rule")}
|
||||||
|
okText={$at("OK")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Chain")}</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={ruleDraft.chain}
|
||||||
|
onChange={v => setRuleDraft({ ...ruleDraft, chain: v as FirewallChain })}
|
||||||
|
options={chainOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Source IP")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.sourceIP}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, sourceIP: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Source Port")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleSourcePortText}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleSourcePortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Protocol")}</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{commProtocolOptions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p.key}
|
||||||
|
checked={ruleDraft.protocols.includes(p.key)}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = new Set(ruleDraft.protocols);
|
||||||
|
if (checked) next.add(p.key);
|
||||||
|
else next.delete(p.key);
|
||||||
|
const arr = [...next.values()];
|
||||||
|
setRuleDraft({ ...ruleDraft, protocols: normalizeRuleProtocols(arr) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Destination IP")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.destinationIP}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, destinationIP: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Destination Port")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDestinationPortText}
|
||||||
|
placeholder={$at("Any")}
|
||||||
|
onChange={e => setRuleDestinationPortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Action")}</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={ruleDraft.action}
|
||||||
|
onChange={v => setRuleDraft({ ...ruleDraft, action: v as FirewallAction })}
|
||||||
|
options={actionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Description")}</div>
|
||||||
|
<Input
|
||||||
|
value={ruleDraft.comment}
|
||||||
|
onChange={e => setRuleDraft({ ...ruleDraft, comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={pfModalOpen}
|
||||||
|
onCancel={() => setPfModalOpen(false)}
|
||||||
|
onOk={savePortForwardDraft}
|
||||||
|
confirmLoading={applying}
|
||||||
|
title={$at(pfEditingIndex === null ? "Add Rule" : "Edit Rule")}
|
||||||
|
okText={$at("OK")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Chain")}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
className="!w-full"
|
||||||
|
value={pfDraft.chain ?? "prerouting"}
|
||||||
|
onChange={v => {
|
||||||
|
const c = v as "output" | "prerouting" | "prerouting_redirect";
|
||||||
|
const curDst = pfDraft.destinationIP.trim();
|
||||||
|
const forceLocal = c === "output" || c === "prerouting_redirect";
|
||||||
|
setPfDraft({
|
||||||
|
...pfDraft,
|
||||||
|
chain: c,
|
||||||
|
destinationIP: forceLocal
|
||||||
|
? "0.0.0.0"
|
||||||
|
: curDst === "0.0.0.0" || curDst === "127.0.0.1"
|
||||||
|
? ""
|
||||||
|
: pfDraft.destinationIP,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "prerouting", label: "PREROUTING" },
|
||||||
|
{ value: "prerouting_redirect", label: "PREROUTING_REDIRECT" },
|
||||||
|
{ value: "output", label: "OUTPUT" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Source Port")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfSourcePortText}
|
||||||
|
onChange={e => setPfSourcePortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Protocol")}</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{portForwardProtocolOptions.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p.key}
|
||||||
|
checked={pfDraft.protocols.includes(p.key)}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const next = new Set(pfDraft.protocols);
|
||||||
|
if (checked) next.add(p.key);
|
||||||
|
else next.delete(p.key);
|
||||||
|
setPfDraft({ ...pfDraft, protocols: [...next.values()] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Destination IP")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfDraft.destinationIP}
|
||||||
|
onChange={e => setPfDraft({ ...pfDraft, destinationIP: e.target.value })}
|
||||||
|
disabled={(pfDraft.chain ?? "prerouting") !== "prerouting"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-red-500">*</span> {$at("Destination Port")}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={pfDestinationPortText}
|
||||||
|
onChange={e => setPfDestinationPortText(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">{$at("Description")}</div>
|
||||||
|
<Input
|
||||||
|
value={pfDraft.comment}
|
||||||
|
onChange={e => setPfDraft({ ...pfDraft, comment: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showBaseSubmitConfirm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowBaseSubmitConfirm(false);
|
||||||
|
}}
|
||||||
|
title={$at("Submit Firewall Policies?")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{$at(
|
||||||
|
"Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant="warning"
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
confirmText={$at("Submit")}
|
||||||
|
onConfirm={handleBaseSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,8 +19,14 @@ export default function SettingsAdvanced() {
|
|||||||
const [sshKey, setSSHKey] = useState<string>("");
|
const [sshKey, setSSHKey] = useState<string>("");
|
||||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||||
|
const [usbEnhancedDetectionEnabled, setUsbEnhancedDetectionEnabled] =
|
||||||
|
useState(true);
|
||||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||||
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
||||||
|
const [showConfigEdit, setShowConfigEdit] = useState(false);
|
||||||
|
const [showConfigSavedReboot, setShowConfigSavedReboot] = useState(false);
|
||||||
|
const [configContent, setConfigContent] = useState("");
|
||||||
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
@@ -38,6 +44,11 @@ export default function SettingsAdvanced() {
|
|||||||
setUsbEmulationEnabled(resp.result as boolean);
|
setUsbEmulationEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
send("getUsbEnhancedDetection", {}, resp => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setUsbEnhancedDetectionEnabled(resp.result as boolean);
|
||||||
|
});
|
||||||
|
|
||||||
send("getLocalLoopbackOnly", {}, resp => {
|
send("getLocalLoopbackOnly", {}, resp => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
setLocalLoopbackOnly(resp.result as boolean);
|
setLocalLoopbackOnly(resp.result as boolean);
|
||||||
@@ -67,6 +78,24 @@ export default function SettingsAdvanced() {
|
|||||||
[getUsbEmulationState, send],
|
[getUsbEmulationState, send],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUsbEnhancedDetectionToggle = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
send("setUsbEnhancedDetection", { enabled }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to ${enabled ? "enable" : "disable"} USB enhanced detection: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsbEnhancedDetectionEnabled(enabled);
|
||||||
|
notifications.success(
|
||||||
|
enabled ? "USB enhanced detection enabled" : "USB enhanced detection disabled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResetConfig = useCallback(() => {
|
const handleResetConfig = useCallback(() => {
|
||||||
send("resetConfig", {}, resp => {
|
send("resetConfig", {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -133,6 +162,35 @@ export default function SettingsAdvanced() {
|
|||||||
setShowLoopbackWarning(false);
|
setShowLoopbackWarning(false);
|
||||||
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
||||||
|
|
||||||
|
const handleOpenConfigEditor = useCallback(() => {
|
||||||
|
send("getConfigRaw", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to load configuration: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigContent(resp.result as string);
|
||||||
|
setShowConfigEdit(true);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback(() => {
|
||||||
|
setIsSavingConfig(true);
|
||||||
|
send("setConfigRaw", { configStr: configContent }, resp => {
|
||||||
|
setIsSavingConfig(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to save configuration: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("Configuration saved successfully");
|
||||||
|
setShowConfigEdit(false);
|
||||||
|
setShowConfigSavedReboot(true);
|
||||||
|
});
|
||||||
|
}, [send, configContent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
@@ -197,6 +255,17 @@ export default function SettingsAdvanced() {
|
|||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("USB detection enhancement")}
|
||||||
|
description={$at("The DISC state is also checked during USB status retrieval")}
|
||||||
|
noCol
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={usbEnhancedDetectionEnabled}
|
||||||
|
onChange={e => handleUsbEnhancedDetectionToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("USB Emulation")}
|
title={$at("USB Emulation")}
|
||||||
description={$at("Control the USB emulation state")}
|
description={$at("Control the USB emulation state")}
|
||||||
@@ -256,15 +325,24 @@ export default function SettingsAdvanced() {
|
|||||||
</AntdButton>
|
</AntdButton>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("Edit Configuration")}
|
||||||
|
description={$at("Edit the raw configuration file directly")}
|
||||||
|
>
|
||||||
|
<AntdButton
|
||||||
|
type="primary"
|
||||||
|
className={`${isMobile?"w-full":""}`}
|
||||||
|
onClick={handleOpenConfigEditor}
|
||||||
|
>
|
||||||
|
{$at("Edit")}
|
||||||
|
</AntdButton>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={$at("Reset Configuration")}
|
title={$at("Reset Configuration")}
|
||||||
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
||||||
>
|
>
|
||||||
|
|
||||||
<AntdButton
|
<AntdButton
|
||||||
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className={`${isMobile?"w-full":""}`}
|
className={`${isMobile?"w-full":""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -333,6 +411,61 @@ export default function SettingsAdvanced() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfigEdit}
|
||||||
|
onClose={() => setShowConfigEdit(false)}
|
||||||
|
title={$at("Edit Configuration")}
|
||||||
|
description={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
{$at("Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.")}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={configContent}
|
||||||
|
onChange={e => setConfigContent(e.target.value)}
|
||||||
|
className="w-full h-64 p-3 font-mono text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:text-white"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
variant="info"
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
confirmText={isSavingConfig ? `${$at("Saving")}...` : $at("Save")}
|
||||||
|
onConfirm={handleSaveConfig}
|
||||||
|
isConfirming={isSavingConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showConfigSavedReboot}
|
||||||
|
onClose={() => setShowConfigSavedReboot(false)}
|
||||||
|
title={$at("Configuration Saved")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{$at("Configuration has been saved successfully. Some changes may require a system restart to take effect.")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||||
|
{$at("Would you like to restart the system now?")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant="info"
|
||||||
|
cancelText={$at("Later")}
|
||||||
|
confirmText={$at("Restart Now")}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowConfigSavedReboot(false);
|
||||||
|
send("reboot", { force: false }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("System rebooting...");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export default function SettingsNetwork() {
|
|||||||
const [networkSettings, setNetworkSettings] =
|
const [networkSettings, setNetworkSettings] =
|
||||||
useState<NetworkSettings>(defaultNetworkSettings);
|
useState<NetworkSettings>(defaultNetworkSettings);
|
||||||
|
|
||||||
|
const [macAddressInput, setMacAddressInput] = useState<string>("");
|
||||||
|
const macAddressTouched = useRef(false);
|
||||||
|
const initialMacAddress = useRef<string>("");
|
||||||
|
|
||||||
// We use this to determine whether the settings have changed
|
// We use this to determine whether the settings have changed
|
||||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||||
// We use this to indicate whether saved settings differ from initial (effective) settings
|
// We use this to indicate whether saved settings differ from initial (effective) settings
|
||||||
@@ -165,8 +169,37 @@ export default function SettingsNetwork() {
|
|||||||
});
|
});
|
||||||
}, [send, setNetworkState]);
|
}, [send, setNetworkState]);
|
||||||
|
|
||||||
|
const normalizeMacAddress = useCallback((value: string) => {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isValidMacAddress = useCallback((value: string) => {
|
||||||
|
const v = normalizeMacAddress(value);
|
||||||
|
return /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(v);
|
||||||
|
}, [normalizeMacAddress]);
|
||||||
|
|
||||||
const setNetworkSettingsRemote = useCallback(
|
const setNetworkSettingsRemote = useCallback(
|
||||||
(settings: NetworkSettings) => {
|
(settings: NetworkSettings) => {
|
||||||
|
const currentMac = (networkState?.mac_address || "").toLowerCase();
|
||||||
|
const newMac = normalizeMacAddress(macAddressInput);
|
||||||
|
const macChanged = newMac !== currentMac;
|
||||||
|
|
||||||
|
if (macChanged) {
|
||||||
|
if (!isValidMacAddress(macAddressInput)) {
|
||||||
|
notifications.error("Please enter a valid MAC address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingMacAddress(newMac);
|
||||||
|
setShowMacChangeConfirm(true);
|
||||||
|
} else {
|
||||||
|
saveNetworkSettings(settings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[networkState?.mac_address, macAddressInput, isValidMacAddress, normalizeMacAddress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveNetworkSettings = useCallback(
|
||||||
|
(settings: NetworkSettings, onSaved?: () => void) => {
|
||||||
setNetworkSettingsLoaded(false);
|
setNetworkSettingsLoaded(false);
|
||||||
send("setNetworkSettings", { settings }, resp => {
|
send("setNetworkSettings", { settings }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -177,12 +210,13 @@ export default function SettingsNetwork() {
|
|||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
|
|
||||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||||
setNetworkSettings(resp.result as NetworkSettings);
|
setNetworkSettings(resp.result as NetworkSettings);
|
||||||
|
macAddressTouched.current = false;
|
||||||
getNetworkState();
|
getNetworkState();
|
||||||
setNetworkSettingsLoaded(true);
|
setNetworkSettingsLoaded(true);
|
||||||
notifications.success("Network settings saved");
|
notifications.success("Network settings saved");
|
||||||
|
onSaved?.();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[getNetworkState, send],
|
[getNetworkState, send],
|
||||||
@@ -203,6 +237,14 @@ export default function SettingsNetwork() {
|
|||||||
getNetworkSettings();
|
getNetworkSettings();
|
||||||
}, [getNetworkState, getNetworkSettings]);
|
}, [getNetworkState, getNetworkSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (networkState?.mac_address && initialMacAddress.current === "") {
|
||||||
|
const normalized = networkState.mac_address.toLowerCase();
|
||||||
|
setMacAddressInput(normalized);
|
||||||
|
initialMacAddress.current = normalized;
|
||||||
|
}
|
||||||
|
}, [networkState?.mac_address]);
|
||||||
|
|
||||||
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
||||||
const newMode = value as IPv4Mode;
|
const newMode = value as IPv4Mode;
|
||||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
||||||
@@ -290,6 +332,8 @@ export default function SettingsNetwork() {
|
|||||||
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
||||||
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
||||||
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
||||||
|
const [showMacChangeConfirm, setShowMacChangeConfirm] = useState(false);
|
||||||
|
const [pendingMacAddress, setPendingMacAddress] = useState("");
|
||||||
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
||||||
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
||||||
|
|
||||||
@@ -336,19 +380,13 @@ export default function SettingsNetwork() {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={networkState?.mac_address}
|
value={macAddressInput}
|
||||||
readOnly={true}
|
onChange={e => {
|
||||||
|
macAddressTouched.current = true;
|
||||||
|
setMacAddressInput(e.target.value);
|
||||||
|
}}
|
||||||
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
|
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
|
||||||
/>
|
/>
|
||||||
{/*<InputField*/}
|
|
||||||
{/* type="text"*/}
|
|
||||||
{/* size="SM"*/}
|
|
||||||
{/* value={networkState?.mac_address}*/}
|
|
||||||
{/* error={""}*/}
|
|
||||||
{/* readOnly={true}*/}
|
|
||||||
{/* className="dark:!text-opacity-60 "*/}
|
|
||||||
{/*/>*/}
|
|
||||||
|
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -502,8 +540,9 @@ export default function SettingsNetwork() {
|
|||||||
<AntdButton
|
<AntdButton
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={
|
disabled={
|
||||||
firstNetworkSettings.current === networkSettings ||
|
(!macAddressTouched.current && firstNetworkSettings.current === networkSettings) ||
|
||||||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
|
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static") ||
|
||||||
|
(macAddressTouched.current && !isValidMacAddress(macAddressInput))
|
||||||
}
|
}
|
||||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||||
className={isMobile ? "w-full" : ""}
|
className={isMobile ? "w-full" : ""}
|
||||||
@@ -797,6 +836,31 @@ export default function SettingsNetwork() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showMacChangeConfirm}
|
||||||
|
onClose={() => setShowMacChangeConfirm(false)}
|
||||||
|
title={$at("Change MAC Address?")}
|
||||||
|
description={$at("Changing the MAC address may cause the device IP to be reassigned and changed.")}
|
||||||
|
variant="warning"
|
||||||
|
confirmText={$at("Confirm")}
|
||||||
|
cancelText={$at("Cancel")}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowMacChangeConfirm(false);
|
||||||
|
send("setEthernetMacAddress", { macAddress: pendingMacAddress }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
"Failed to apply MAC address: " +
|
||||||
|
(resp.error.data ? resp.error.data : resp.error.message),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNetworkState(resp.result as NetworkState);
|
||||||
|
saveNetworkSettings(networkSettings, () => {
|
||||||
|
initialMacAddress.current = pendingMacAddress;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from "@components/Button";
|
|||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { formatters } from "@/utils";
|
||||||
|
|
||||||
export interface SystemVersionInfo {
|
export interface SystemVersionInfo {
|
||||||
local: { appVersion: string; systemVersion: string };
|
local: { appVersion: string; systemVersion: string };
|
||||||
@@ -37,6 +38,7 @@ export default function SettingsVersion() {
|
|||||||
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [updateSource, setUpdateSource] = useState("github");
|
const [updateSource, setUpdateSource] = useState("github");
|
||||||
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
||||||
|
const [updateDownloadProxy, setUpdateDownloadProxy] = useState("");
|
||||||
|
|
||||||
const currentVersions = useDeviceStore(state => {
|
const currentVersions = useDeviceStore(state => {
|
||||||
const { appVersion, systemVersion } = state;
|
const { appVersion, systemVersion } = state;
|
||||||
@@ -58,6 +60,13 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getUpdateDownloadProxy", {}, resp => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setUpdateDownloadProxy(resp.result as string);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||||
send("setAutoUpdateState", { enabled }, resp => {
|
send("setAutoUpdateState", { enabled }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -96,6 +105,18 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
}, [customUpdateBaseURL, send]);
|
}, [customUpdateBaseURL, send]);
|
||||||
|
|
||||||
|
const applyUpdateDownloadProxy = useCallback(() => {
|
||||||
|
send("setUpdateDownloadProxy", { proxy: updateDownloadProxy }, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(
|
||||||
|
`Failed to save update download proxy: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success("Update download proxy applied");
|
||||||
|
});
|
||||||
|
}, [send, updateDownloadProxy]);
|
||||||
|
|
||||||
const closeUpdateDialog = useCallback(() => {
|
const closeUpdateDialog = useCallback(() => {
|
||||||
setIsUpdateDialogOpen(false);
|
setIsUpdateDialogOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -211,7 +232,14 @@ export default function SettingsVersion() {
|
|||||||
|
|
||||||
{isUpdateDialogOpen && (
|
{isUpdateDialogOpen && (
|
||||||
<div ref={updatePanelRef} className="pt-2">
|
<div ref={updatePanelRef} className="pt-2">
|
||||||
<UpdateContent onClose={closeUpdateDialog} onConfirmUpdate={onConfirmUpdate} />
|
<UpdateContent
|
||||||
|
onClose={closeUpdateDialog}
|
||||||
|
onConfirmUpdate={onConfirmUpdate}
|
||||||
|
updateSource={updateSource}
|
||||||
|
updateDownloadProxy={updateDownloadProxy}
|
||||||
|
onUpdateDownloadProxyChange={setUpdateDownloadProxy}
|
||||||
|
onSaveUpdateDownloadProxy={applyUpdateDownloadProxy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,9 +306,17 @@ function UpdateSourceSettings({
|
|||||||
function UpdateContent({
|
function UpdateContent({
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmUpdate,
|
onConfirmUpdate,
|
||||||
|
updateSource,
|
||||||
|
updateDownloadProxy,
|
||||||
|
onUpdateDownloadProxyChange,
|
||||||
|
onSaveUpdateDownloadProxy,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmUpdate: () => void;
|
onConfirmUpdate: () => void;
|
||||||
|
updateSource: string;
|
||||||
|
updateDownloadProxy: string;
|
||||||
|
onUpdateDownloadProxyChange: (proxy: string) => void;
|
||||||
|
onSaveUpdateDownloadProxy: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||||
@@ -324,6 +360,10 @@ function UpdateContent({
|
|||||||
onConfirmUpdate={onConfirmUpdate}
|
onConfirmUpdate={onConfirmUpdate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
versionInfo={versionInfo!}
|
versionInfo={versionInfo!}
|
||||||
|
updateSource={updateSource}
|
||||||
|
updateDownloadProxy={updateDownloadProxy}
|
||||||
|
onUpdateDownloadProxyChange={onUpdateDownloadProxyChange}
|
||||||
|
onSaveUpdateDownloadProxy={onSaveUpdateDownloadProxy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -482,11 +522,14 @@ function UpdatingDeviceState({
|
|||||||
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
||||||
const verfiedAt = otaState[`${type}VerifiedAt`];
|
const verfiedAt = otaState[`${type}VerifiedAt`];
|
||||||
const updatedAt = otaState[`${type}UpdatedAt`];
|
const updatedAt = otaState[`${type}UpdatedAt`];
|
||||||
|
const downloadSpeedBps = (otaState as any)[`${type}DownloadSpeedBps`] as number | undefined;
|
||||||
|
const formattedSpeed =
|
||||||
|
downloadSpeedBps && downloadSpeedBps > 0 ? `${formatters.bytes(downloadSpeedBps, 1)}/s` : null;
|
||||||
|
|
||||||
if (!otaState.metadataFetchedAt) {
|
if (!otaState.metadataFetchedAt) {
|
||||||
return "Fetching update information...";
|
return "Fetching update information...";
|
||||||
} else if (!downloadFinishedAt) {
|
} else if (!downloadFinishedAt) {
|
||||||
return `Downloading ${type} update...`;
|
return formattedSpeed ? `Downloading ${type} update... (${formattedSpeed})` : `Downloading ${type} update...`;
|
||||||
} else if (!verfiedAt) {
|
} else if (!verfiedAt) {
|
||||||
return `Verifying ${type} update...`;
|
return `Verifying ${type} update...`;
|
||||||
} else if (!updatedAt) {
|
} else if (!updatedAt) {
|
||||||
@@ -651,10 +694,18 @@ function UpdateAvailableState({
|
|||||||
versionInfo,
|
versionInfo,
|
||||||
onConfirmUpdate,
|
onConfirmUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
|
updateSource,
|
||||||
|
updateDownloadProxy,
|
||||||
|
onUpdateDownloadProxyChange,
|
||||||
|
onSaveUpdateDownloadProxy,
|
||||||
}: {
|
}: {
|
||||||
versionInfo: SystemVersionInfo;
|
versionInfo: SystemVersionInfo;
|
||||||
onConfirmUpdate: () => void;
|
onConfirmUpdate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
updateSource: string;
|
||||||
|
updateDownloadProxy: string;
|
||||||
|
onUpdateDownloadProxyChange: (proxy: string) => void;
|
||||||
|
onSaveUpdateDownloadProxy: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
return (
|
return (
|
||||||
@@ -681,6 +732,21 @@ function UpdateAvailableState({
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{updateSource === "github" && (
|
||||||
|
<div className="mb-4 flex items-end gap-x-2">
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
label={$at("Download Proxy Prefix")}
|
||||||
|
value={updateDownloadProxy}
|
||||||
|
onChange={e => onUpdateDownloadProxyChange(e.target.value)}
|
||||||
|
placeholder="https://gh-proxy.com/"
|
||||||
|
/>
|
||||||
|
<AntdButton type="primary" onClick={onSaveUpdateDownloadProxy}>
|
||||||
|
{$at("Apply")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-start gap-x-2">
|
<div className="flex items-center justify-start gap-x-2">
|
||||||
<AntdButton type="primary" onClick={onConfirmUpdate}>
|
<AntdButton type="primary" onClick={onConfirmUpdate}>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ interface StorageFilePageProps {
|
|||||||
showSDManagement?: boolean;
|
showSDManagement?: boolean;
|
||||||
onResetSDStorage?: () => void;
|
onResetSDStorage?: () => void;
|
||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
|
onFormatSDStorage?: () => void;
|
||||||
onMountSDStorage?: () => void;
|
onMountSDStorage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
showSDManagement = false,
|
showSDManagement = false,
|
||||||
onResetSDStorage,
|
onResetSDStorage,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
|
onFormatSDStorage,
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
@@ -212,6 +214,16 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
}
|
}
|
||||||
}, [onResetSDStorage, syncStorage]);
|
}, [onResetSDStorage, syncStorage]);
|
||||||
|
|
||||||
|
const handleFormatWrapper = useCallback(async () => {
|
||||||
|
if (onFormatSDStorage) {
|
||||||
|
setLoading(true);
|
||||||
|
await onFormatSDStorage();
|
||||||
|
setSDMountStatus(null);
|
||||||
|
syncStorage();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [onFormatSDStorage, syncStorage]);
|
||||||
|
|
||||||
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
|
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl py-8">
|
<div className="mx-auto max-w-4xl py-8">
|
||||||
@@ -237,6 +249,19 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
? $at("Please insert an SD card and try again.")
|
? $at("Please insert an SD card and try again.")
|
||||||
: $at("Please format the SD card and try again.")}
|
: $at("Please format the SD card and try again.")}
|
||||||
</p>
|
</p>
|
||||||
|
{sdMountStatus !== "none" && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
danger={true}
|
||||||
|
type="primary"
|
||||||
|
onClick={handleFormatWrapper}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{$at("Format MicroSD Card")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -291,6 +316,7 @@ export const FileManager: React.FC<StorageFilePageProps> = ({
|
|||||||
showSDManagement={showSDManagement}
|
showSDManagement={showSDManagement}
|
||||||
onNewImageClick={handleNewImageClick}
|
onNewImageClick={handleNewImageClick}
|
||||||
onUnmountSDStorage={handleUnmountWrapper}
|
onUnmountSDStorage={handleUnmountWrapper}
|
||||||
|
onFormatSDStorage={handleFormatWrapper}
|
||||||
syncStorage={syncStorage}
|
syncStorage={syncStorage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -476,6 +502,7 @@ interface ActionButtonsSectionProps {
|
|||||||
showSDManagement?: boolean;
|
showSDManagement?: boolean;
|
||||||
onNewImageClick: (incompleteFileName?: string) => void;
|
onNewImageClick: (incompleteFileName?: string) => void;
|
||||||
onUnmountSDStorage?: () => void;
|
onUnmountSDStorage?: () => void;
|
||||||
|
onFormatSDStorage?: () => void;
|
||||||
syncStorage: () => void;
|
syncStorage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,15 +511,22 @@ const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
|||||||
loading,
|
loading,
|
||||||
showSDManagement,
|
showSDManagement,
|
||||||
onUnmountSDStorage,
|
onUnmountSDStorage,
|
||||||
|
onFormatSDStorage,
|
||||||
}) => {
|
}) => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
if (mediaType === "sd" && showSDManagement) {
|
if (mediaType === "sd" && showSDManagement) {
|
||||||
return (
|
return (
|
||||||
<div className="flex animate-fadeIn justify-between opacity-0"
|
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
type="primary"
|
||||||
|
danger={true}
|
||||||
|
onClick={onFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>{$at("Format MicroSD Card")}</AntdButton>
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -503,4 +537,4 @@ const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
|
||||||
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
export default function SDFilePage() {
|
export default function SDFilePage() {
|
||||||
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -34,6 +36,23 @@ export default function SDFilePage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFormatSDStorage = async () => {
|
||||||
|
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
send("formatSDStorage", { confirm: true }, res => {
|
||||||
|
if ("error" in res) {
|
||||||
|
notifications.error(res.error.data || res.error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success($at("SD card formatted successfully"));
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileManager
|
<FileManager
|
||||||
mediaType="sd"
|
mediaType="sd"
|
||||||
@@ -45,6 +64,7 @@ export default function SDFilePage() {
|
|||||||
showSDManagement={true}
|
showSDManagement={true}
|
||||||
onResetSDStorage={handleResetSDStorage}
|
onResetSDStorage={handleResetSDStorage}
|
||||||
onUnmountSDStorage={handleUnmountSDStorage}
|
onUnmountSDStorage={handleUnmountSDStorage}
|
||||||
|
onFormatSDStorage={handleFormatSDStorage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import React from "react";
|
|||||||
import SideTabs from "@components/Sidebar/SideTabs";
|
import SideTabs from "@components/Sidebar/SideTabs";
|
||||||
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
|
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
|
||||||
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
|
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
|
||||||
|
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||||
|
|
||||||
|
|
||||||
const SharedFolders: React.FC = () => {
|
const SharedFolders: React.FC = () => {
|
||||||
|
const { bootStorageType } = useBootStorageType();
|
||||||
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
|
|
||||||
|
if (isBootFromSD) {
|
||||||
|
return <DeviceFilePage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideTabs
|
<SideTabs
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface StorageSpace {
|
|||||||
bytesFree: number;
|
bytesFree: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMountableVirtualMediaFile = (filename: string) => {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
return lower.endsWith(".img") || lower.endsWith(".iso");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const LoadingOverlay: React.FC = () => {
|
const LoadingOverlay: React.FC = () => {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
@@ -89,7 +94,6 @@ export default function ImageManager({
|
|||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const [storageFiles, setStorageFiles] = useState<StorageFile[]>([]);
|
const [storageFiles, setStorageFiles] = useState<StorageFile[]>([]);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
|
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
|
||||||
@@ -103,7 +107,6 @@ export default function ImageManager({
|
|||||||
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
||||||
const filesPerPage = 5;
|
const filesPerPage = 5;
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const percentageUsed = useMemo(() => {
|
const percentageUsed = useMemo(() => {
|
||||||
if (!storageSpace) return 0;
|
if (!storageSpace) return 0;
|
||||||
return Number(
|
return Number(
|
||||||
@@ -162,13 +165,30 @@ export default function ImageManager({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据获取函数
|
const handleFormatSDStorage = async () => {
|
||||||
|
if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
send("formatSDStorage", { confirm: true }, res => {
|
||||||
|
if ("error" in res) {
|
||||||
|
notifications.error(res.error.data || res.error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.success($at("SD card formatted successfully"));
|
||||||
|
setSDMountStatus(null);
|
||||||
|
checkSDStatus();
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const syncStorage = useCallback(() => {
|
const syncStorage = useCallback(() => {
|
||||||
if (storageType === 'sd' && sdMountStatus !== 'ok') {
|
if (storageType === 'sd' && sdMountStatus !== 'ok') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取存储文件列表
|
|
||||||
send(listFilesApi, {}, res => {
|
send(listFilesApi, {}, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(`Error listing storage files: ${res.error}`);
|
notifications.error(`Error listing storage files: ${res.error}`);
|
||||||
@@ -180,10 +200,11 @@ export default function ImageManager({
|
|||||||
size: formatters.bytes(file.size),
|
size: formatters.bytes(file.size),
|
||||||
createdAt: formatters.date(new Date(file?.createdAt)),
|
createdAt: formatters.date(new Date(file?.createdAt)),
|
||||||
}));
|
}));
|
||||||
setStorageFiles(formattedFiles);
|
const mountableFiles = formattedFiles.filter(f => isMountableVirtualMediaFile(f.name));
|
||||||
|
setStorageFiles(mountableFiles);
|
||||||
|
setSelectedFile(prev => (prev && mountableFiles.some(f => f.name === prev) ? prev : null));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取存储空间信息
|
|
||||||
send(getSpaceApi, {}, res => {
|
send(getSpaceApi, {}, res => {
|
||||||
if ("error" in res) {
|
if ("error" in res) {
|
||||||
notifications.error(`Error getting storage space: ${res.error}`);
|
notifications.error(`Error getting storage space: ${res.error}`);
|
||||||
@@ -192,7 +213,6 @@ export default function ImageManager({
|
|||||||
setStorageSpace(res.result as StorageSpace);
|
setStorageSpace(res.result as StorageSpace);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取自动挂载设置
|
|
||||||
if (showAutoMount && getAutoMountApi) {
|
if (showAutoMount && getAutoMountApi) {
|
||||||
send(getAutoMountApi, {}, resp => {
|
send(getAutoMountApi, {}, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
@@ -205,7 +225,6 @@ export default function ImageManager({
|
|||||||
}
|
}
|
||||||
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
|
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
|
||||||
|
|
||||||
// 初始化数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storageType === 'sd') {
|
if (storageType === 'sd') {
|
||||||
checkSDStatus();
|
checkSDStatus();
|
||||||
@@ -220,7 +239,6 @@ export default function ImageManager({
|
|||||||
}
|
}
|
||||||
}, [sdMountStatus, syncStorage]);
|
}, [sdMountStatus, syncStorage]);
|
||||||
|
|
||||||
// 事件处理函数
|
|
||||||
const handleDeleteFile = useCallback((file: StorageFile) => {
|
const handleDeleteFile = useCallback((file: StorageFile) => {
|
||||||
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
|
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
|
||||||
send(deleteFileApi, { filename: file.name }, res => {
|
send(deleteFileApi, { filename: file.name }, res => {
|
||||||
@@ -235,9 +253,10 @@ export default function ImageManager({
|
|||||||
|
|
||||||
const handleSelectFile = useCallback((file: StorageFile) => {
|
const handleSelectFile = useCallback((file: StorageFile) => {
|
||||||
setSelectedFile(file.name);
|
setSelectedFile(file.name);
|
||||||
if (file.name.endsWith(".iso")) {
|
const lower = file.name.toLowerCase();
|
||||||
|
if (lower.endsWith(".iso")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("CDROM");
|
||||||
} else if (file.name.endsWith(".img")) {
|
} else if (lower.endsWith(".img")) {
|
||||||
setUsbMode("Disk");
|
setUsbMode("Disk");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -263,7 +282,6 @@ export default function ImageManager({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂载成功
|
|
||||||
syncRemoteVirtualMediaState()
|
syncRemoteVirtualMediaState()
|
||||||
setMountInProgress(false);
|
setMountInProgress(false);
|
||||||
if (onMountSuccess) {
|
if (onMountSuccess) {
|
||||||
@@ -327,6 +345,19 @@ export default function ImageManager({
|
|||||||
? $at("Please insert an SD card and try again.")
|
? $at("Please insert an SD card and try again.")
|
||||||
: $at("Please format the SD card and try again.")}
|
: $at("Please format the SD card and try again.")}
|
||||||
</p>
|
</p>
|
||||||
|
{sdMountStatus !== "none" && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
danger={true}
|
||||||
|
type="primary"
|
||||||
|
onClick={handleFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{$at("Format MicroSD Card")}
|
||||||
|
</AntdButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +374,6 @@ export default function ImageManager({
|
|||||||
title={$at("Mount from KVM Storage")}
|
title={$at("Mount from KVM Storage")}
|
||||||
description={$at("Select the image you want to mount from the KVM storage")}
|
description={$at("Select the image you want to mount from the KVM storage")}
|
||||||
/>
|
/>
|
||||||
{/* 自动挂载设置 */}
|
|
||||||
{showAutoMount && (
|
{showAutoMount && (
|
||||||
<div className="w-full animate-fadeIn opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
<div className="w-full animate-fadeIn opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
@@ -361,7 +391,6 @@ export default function ImageManager({
|
|||||||
|
|
||||||
{showAutoMount && <hr className="border-slate-800/20 dark:border-slate-300/20" />}
|
{showAutoMount && <hr className="border-slate-800/20 dark:border-slate-300/20" />}
|
||||||
|
|
||||||
{/* 文件列表 */}
|
|
||||||
<div className="w-full animate-fadeIn opacity-0 px-0.5" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
<div className="w-full animate-fadeIn opacity-0 px-0.5" style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -402,7 +431,6 @@ export default function ImageManager({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 分页控件 */}
|
|
||||||
{storageFiles.length > filesPerPage && (
|
{storageFiles.length > filesPerPage && (
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
@@ -438,7 +466,6 @@ export default function ImageManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
{storageFiles.length > 0 && (
|
{storageFiles.length > 0 && (
|
||||||
<div className="flex animate-fadeIn items-end justify-between opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.15s" }}>
|
<div className="flex animate-fadeIn items-end justify-between opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.15s" }}>
|
||||||
<Fieldset disabled={selectedFile === null}>
|
<Fieldset disabled={selectedFile === null}>
|
||||||
@@ -456,7 +483,6 @@ export default function ImageManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 存储空间信息 */}
|
|
||||||
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
||||||
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
|
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
|
||||||
<StorageSpaceBar
|
<StorageSpaceBar
|
||||||
@@ -465,12 +491,18 @@ export default function ImageManager({
|
|||||||
bytesFree={storageSpace?.bytesFree || 0}
|
bytesFree={storageSpace?.bytesFree || 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 自定义操作区域 */}
|
|
||||||
{unmountApi && storageType === 'sd' && (
|
{unmountApi && storageType === 'sd' && (
|
||||||
<div className="flex animate-fadeIn justify-between opacity-0"
|
<div className="flex animate-fadeIn justify-between gap-2 opacity-0"
|
||||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||||
>
|
>
|
||||||
|
<AntdButton
|
||||||
|
disabled={loading}
|
||||||
|
type="primary"
|
||||||
|
danger={true}
|
||||||
|
onClick={handleFormatSDStorage}
|
||||||
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||||
|
>{$at("Format MicroSD Card")}</AntdButton>
|
||||||
<AntdButton
|
<AntdButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -482,7 +514,6 @@ export default function ImageManager({
|
|||||||
)}
|
)}
|
||||||
{customActions}
|
{customActions}
|
||||||
|
|
||||||
{/* 上传新镜像按钮 */}
|
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
<FileUploader
|
<FileUploader
|
||||||
key={`resume-${uploadFile}`}
|
key={`resume-${uploadFile}`}
|
||||||
@@ -492,12 +523,14 @@ export default function ImageManager({
|
|||||||
}}
|
}}
|
||||||
incompleteFileName={uploadFile}
|
incompleteFileName={uploadFile}
|
||||||
media={storageType}
|
media={storageType}
|
||||||
|
accept=".img,.iso"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileUploader
|
<FileUploader
|
||||||
key="new-upload"
|
key="new-upload"
|
||||||
onBack={handleFileUploadComplete}
|
onBack={handleFileUploadComplete}
|
||||||
media={storageType}
|
media={storageType}
|
||||||
|
accept=".img,.iso"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import SideTabs from "@components/Sidebar/SideTabs";
|
|||||||
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
|
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
|
||||||
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
|
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
|
||||||
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
|
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
|
||||||
|
import { useBootStorageType } from "@/hooks/useBootStorage";
|
||||||
|
|
||||||
|
|
||||||
////* KVM MicroSD Mount */
|
|
||||||
// width: 143px;
|
|
||||||
// height: 11px;
|
|
||||||
// display: flex;
|
|
||||||
// flex-direction: row;
|
|
||||||
// align-items: center;
|
|
||||||
// 主组件
|
|
||||||
const VirtualMediaSource: React.FC = () => {
|
const VirtualMediaSource: React.FC = () => {
|
||||||
|
const { bootStorageType } = useBootStorageType();
|
||||||
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
|
|
||||||
|
if (isBootFromSD) {
|
||||||
|
return (
|
||||||
|
<UnMountPage unmountedPage={<DevicePage />} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnMountPage unmountedPage={(
|
<UnMountPage unmountedPage={(
|
||||||
@@ -25,7 +26,6 @@ const VirtualMediaSource: React.FC = () => {
|
|||||||
defaultActiveKey="1"
|
defaultActiveKey="1"
|
||||||
/>
|
/>
|
||||||
)}/>
|
)}/>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { cx } from "@/cva.config";
|
|||||||
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
|
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
|
||||||
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
|
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
|
||||||
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
|
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
|
||||||
|
import { useFullscreen } from "@/layout/core/desktop/hooks/useFullscreen";
|
||||||
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
|
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
|
||||||
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
|
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
|
||||||
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
|
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
|
||||||
@@ -25,7 +26,7 @@ import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
|||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
|
|
||||||
export default function PCDesktop() {
|
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
const audioElm = useRef<HTMLAudioElement>(null);
|
const audioElm = useRef<HTMLAudioElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -55,6 +56,7 @@ export default function PCDesktop() {
|
|||||||
const videoEffects = useVideoEffects();
|
const videoEffects = useVideoEffects();
|
||||||
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
const videoStream = useVideoStream(videoElm as React.RefObject<HTMLVideoElement>, audioElm as React.RefObject<HTMLAudioElement>);
|
||||||
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
const pointerLock = usePointerLock(videoElm as React.RefObject<HTMLVideoElement>);
|
||||||
|
useFullscreen(videoElm as React.RefObject<HTMLVideoElement>, pointerLock, isFullscreen);
|
||||||
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject<HTMLDivElement>);
|
||||||
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject<HTMLTextAreaElement>);
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default function Desktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
if(isMobile){
|
if(isMobile){
|
||||||
return <MobileDesktop isFullscreen={isFullscreen}/>
|
return <MobileDesktop isFullscreen={isFullscreen}/>
|
||||||
}
|
}
|
||||||
return <PCDesktop/> ;
|
return <PCDesktop isFullscreen={isFullscreen}/> ;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
|
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
|
||||||
"75316ccce3": "Frame per second",
|
"75316ccce3": "Frame per second",
|
||||||
"75a4e60fe7": "Number of video frames displayed per second.",
|
"75a4e60fe7": "Number of video frames displayed per second.",
|
||||||
|
"867f8b3151": "Only {{types}} files are supported",
|
||||||
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
|
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
|
||||||
"8282fb0974": "Resume Upload",
|
"8282fb0974": "Resume Upload",
|
||||||
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
|
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
|
||||||
"5d2e347cdb": "Click here to upload a new image",
|
"6e8daad8ab": "Click here to upload",
|
||||||
"ddeb0eac69": "Do not support directories",
|
"ddeb0eac69": "Do not support directories",
|
||||||
"3f1c5b0049": "Uploading",
|
"3f1c5b0049": "Uploading",
|
||||||
"f287042190": "Uploading...",
|
"f287042190": "Uploading...",
|
||||||
@@ -250,6 +251,43 @@
|
|||||||
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
||||||
"1bbabcdef3": "Edit frpc.toml",
|
"1bbabcdef3": "Edit frpc.toml",
|
||||||
"9c088a303a": "Enter frpc configuration",
|
"9c088a303a": "Enter frpc configuration",
|
||||||
|
"87ed72fc20": "Failed to get firewall config",
|
||||||
|
"a25ea1e1ee": "Failed to apply firewall config",
|
||||||
|
"bcbc6c71a1": "Firewall config applied",
|
||||||
|
"f4b0e93291": "Please select protocol",
|
||||||
|
"7687f1154f": "Missing required fields",
|
||||||
|
"a133cae395": "Invalid port",
|
||||||
|
"d10b3069c2": "For PREROUTING, Destination IP must be a real host IP",
|
||||||
|
"0834bb6c87": "Destination IP is required",
|
||||||
|
"c5381dc540": "Firewall",
|
||||||
|
"8256c0c407": "Manage the firewall rules of the device",
|
||||||
|
"972e73b7a8": "Basic",
|
||||||
|
"7218073b8c": "Communication Rules",
|
||||||
|
"23fed496dd": "Port Forwarding",
|
||||||
|
"324118a672": "Input",
|
||||||
|
"29c2c02a36": "Output",
|
||||||
|
"67d2f6740a": "Forward",
|
||||||
|
"63a6a88c06": "Refresh",
|
||||||
|
"a4d3b161ce": "Submit",
|
||||||
|
"ec211f7c20": "Add",
|
||||||
|
"5320550175": "Chain",
|
||||||
|
"f31bbdd1b3": "Source",
|
||||||
|
"888a77f5ac": "Protocol",
|
||||||
|
"12007e1d59": "Destination",
|
||||||
|
"004bf6c9a4": "Action",
|
||||||
|
"b5a7adde1a": "Description",
|
||||||
|
"2a78ed7645": "Operation",
|
||||||
|
"095c375025": "No rules available",
|
||||||
|
"ed36a1ef76": "Any",
|
||||||
|
"7dce122004": "Edit",
|
||||||
|
"efba20a02e": "No data available",
|
||||||
|
"e0aa021e21": "OK",
|
||||||
|
"7d367dab8b": "Source IP",
|
||||||
|
"c050956ed2": "Source Port",
|
||||||
|
"94386968c2": "Destination IP",
|
||||||
|
"64ae5dd047": "Destination Port",
|
||||||
|
"fbb3878eb7": "Submit Firewall Policies?",
|
||||||
|
"1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
|
||||||
"867cee98fd": "Passwords do not match",
|
"867cee98fd": "Passwords do not match",
|
||||||
"9864ff9420": "Please enter your old password",
|
"9864ff9420": "Please enter your old password",
|
||||||
"14a714ab22": "Please enter a new password",
|
"14a714ab22": "Please enter a new password",
|
||||||
@@ -292,6 +330,8 @@
|
|||||||
"7a941a0f87": "Update SSH Key",
|
"7a941a0f87": "Update SSH Key",
|
||||||
"fdad65b7be": "Force HTTP Transmission",
|
"fdad65b7be": "Force HTTP Transmission",
|
||||||
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
|
||||||
|
"bda22ca687": "USB detection enhancement",
|
||||||
|
"f7df928057": "The DISC state is also checked during USB status retrieval",
|
||||||
"021fa314ef": "USB Emulation",
|
"021fa314ef": "USB Emulation",
|
||||||
"5c43d74dbd": "Control the USB emulation state",
|
"5c43d74dbd": "Control the USB emulation state",
|
||||||
"f6c8ddbadf": "Disable USB Emulation",
|
"f6c8ddbadf": "Disable USB Emulation",
|
||||||
@@ -302,12 +342,21 @@
|
|||||||
"f5ddf02991": "Reboot System",
|
"f5ddf02991": "Reboot System",
|
||||||
"1dbbf194af": "Restart the device system",
|
"1dbbf194af": "Restart the device system",
|
||||||
"1de72c4fc6": "Reboot",
|
"1de72c4fc6": "Reboot",
|
||||||
|
"c887e5a479": "Edit Configuration",
|
||||||
|
"90b21a0536": "Edit the raw configuration file directly",
|
||||||
"f43c0398a4": "Reset Configuration",
|
"f43c0398a4": "Reset Configuration",
|
||||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||||
"0d784092e8": "Reset Config",
|
"0d784092e8": "Reset Config",
|
||||||
"a776e925bf": "Reboot System?",
|
"a776e925bf": "Reboot System?",
|
||||||
"1f070051ff": "Are you sure you want to reboot the 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.",
|
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||||
|
"919ff9ff77": "Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.",
|
||||||
|
"4e11db406c": "Saving",
|
||||||
|
"f24a0236a3": "Configuration Saved",
|
||||||
|
"b032420c4b": "Configuration has been saved successfully. Some changes may require a system restart to take effect.",
|
||||||
|
"c8850fa947": "Would you like to restart the system now?",
|
||||||
|
"61057a0c84": "Later",
|
||||||
|
"2d9c2140c5": "Restart Now",
|
||||||
"0db377921f": "General",
|
"0db377921f": "General",
|
||||||
"3845ee1693": "Configure device settings and update preferences",
|
"3845ee1693": "Configure device settings and update preferences",
|
||||||
"d721757161": "Theme",
|
"d721757161": "Theme",
|
||||||
@@ -372,6 +421,8 @@
|
|||||||
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
||||||
"cc172c234b": "Change IPv4 Mode?",
|
"cc172c234b": "Change IPv4 Mode?",
|
||||||
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
||||||
|
"0ddd0ae8fd": "Change MAC Address?",
|
||||||
|
"d0158ce3c1": "Changing the MAC address may cause the device IP to be reassigned and changed.",
|
||||||
"34b6cd7517": "Version",
|
"34b6cd7517": "Version",
|
||||||
"a1937e8ac1": "Check the versions of the system and applications",
|
"a1937e8ac1": "Check the versions of the system and applications",
|
||||||
"04a115bdd8": "AppVersion",
|
"04a115bdd8": "AppVersion",
|
||||||
@@ -396,6 +447,7 @@
|
|||||||
"7c81553358": "Check Again",
|
"7c81553358": "Check Again",
|
||||||
"217b416896": "Update available",
|
"217b416896": "Update available",
|
||||||
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
|
||||||
|
"8977c3f0b7": "Download Proxy Prefix",
|
||||||
"99b1054c0f": "Update Now",
|
"99b1054c0f": "Update Now",
|
||||||
"7f3cd4480d": "Do it later",
|
"7f3cd4480d": "Do it later",
|
||||||
"cffae9918d": "Update Error",
|
"cffae9918d": "Update Error",
|
||||||
@@ -413,7 +465,6 @@
|
|||||||
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
|
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
|
||||||
"ffbb410a55": "Deleting",
|
"ffbb410a55": "Deleting",
|
||||||
"3dd2e50646": "Delay Only",
|
"3dd2e50646": "Delay Only",
|
||||||
"7dce122004": "Edit",
|
|
||||||
"5fb63579fc": "Copy",
|
"5fb63579fc": "Copy",
|
||||||
"fc2839fcdf": "+ Add new macro",
|
"fc2839fcdf": "+ Add new macro",
|
||||||
"05fd7d5b9c": "Are you sure you want to delete",
|
"05fd7d5b9c": "Are you sure you want to delete",
|
||||||
@@ -436,6 +487,7 @@
|
|||||||
"21d104a54f": "Processing...",
|
"21d104a54f": "Processing...",
|
||||||
"9844086d90": "No SD Card Detected",
|
"9844086d90": "No SD Card Detected",
|
||||||
"b56e598918": "SD Card Mount Failed",
|
"b56e598918": "SD Card Mount Failed",
|
||||||
|
"16eb8ed6c8": "Format MicroSD Card",
|
||||||
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
|
||||||
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
"1d50425f88": "Manage Shared Folders in KVM Storage",
|
||||||
"74ff4bad28": "No files found",
|
"74ff4bad28": "No files found",
|
||||||
@@ -446,6 +498,8 @@
|
|||||||
"8bf8854beb": "of",
|
"8bf8854beb": "of",
|
||||||
"53e61336bb": "results",
|
"53e61336bb": "results",
|
||||||
"d1f5a81904": "Unmount MicroSD Card",
|
"d1f5a81904": "Unmount MicroSD Card",
|
||||||
|
"827048afc2": "Formatting the SD card will erase all data. Continue?",
|
||||||
|
"5874cf46ff": "SD card formatted successfully",
|
||||||
"7dc25305d4": "Encodec Type",
|
"7dc25305d4": "Encodec Type",
|
||||||
"41d263a988": "Stream Quality",
|
"41d263a988": "Stream Quality",
|
||||||
"41f5283941": "NPU Application",
|
"41f5283941": "NPU Application",
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
|
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
|
||||||
"75316ccce3": "帧率",
|
"75316ccce3": "帧率",
|
||||||
"75a4e60fe7": "每秒显示的视频帧数。",
|
"75a4e60fe7": "每秒显示的视频帧数。",
|
||||||
|
"867f8b3151": "只支持 {{types}} 文件",
|
||||||
"13c27e178b": "请选择 {{fileName}} 继续上传",
|
"13c27e178b": "请选择 {{fileName}} 继续上传",
|
||||||
"8282fb0974": "重新上传",
|
"8282fb0974": "重新上传",
|
||||||
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
|
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
|
||||||
"5d2e347cdb": "点击这里上传新镜像",
|
"6e8daad8ab": "点击这里上传",
|
||||||
"ddeb0eac69": "不支持目录",
|
"ddeb0eac69": "不支持目录",
|
||||||
"3f1c5b0049": "上传中",
|
"3f1c5b0049": "上传中",
|
||||||
"f287042190": "正在上传...",
|
"f287042190": "正在上传...",
|
||||||
@@ -250,6 +251,43 @@
|
|||||||
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
||||||
"1bbabcdef3": "编辑 frpc.toml",
|
"1bbabcdef3": "编辑 frpc.toml",
|
||||||
"9c088a303a": "输入 frpc 配置",
|
"9c088a303a": "输入 frpc 配置",
|
||||||
|
"87ed72fc20": "获取防火墙配置失败",
|
||||||
|
"a25ea1e1ee": "应用防火墙配置失败",
|
||||||
|
"bcbc6c71a1": "防火墙配置已应用",
|
||||||
|
"f4b0e93291": "请选择协议",
|
||||||
|
"7687f1154f": "缺少必填字段",
|
||||||
|
"a133cae395": "无效端口",
|
||||||
|
"d10b3069c2": "对于 PREROUTING,目标 IP 必须是真实主机 IP",
|
||||||
|
"0834bb6c87": "目标 IP 是必填项",
|
||||||
|
"c5381dc540": "防火墙",
|
||||||
|
"8256c0c407": "管理设备的防火墙规则",
|
||||||
|
"972e73b7a8": "基础",
|
||||||
|
"7218073b8c": "通讯规则",
|
||||||
|
"23fed496dd": "端口转发",
|
||||||
|
"324118a672": "输入",
|
||||||
|
"29c2c02a36": "输出",
|
||||||
|
"67d2f6740a": "转发",
|
||||||
|
"63a6a88c06": "刷新",
|
||||||
|
"a4d3b161ce": "提交",
|
||||||
|
"ec211f7c20": "添加",
|
||||||
|
"5320550175": "链",
|
||||||
|
"f31bbdd1b3": "源",
|
||||||
|
"888a77f5ac": "协议",
|
||||||
|
"12007e1d59": "目标",
|
||||||
|
"004bf6c9a4": "操作",
|
||||||
|
"b5a7adde1a": "描述",
|
||||||
|
"2a78ed7645": "操作",
|
||||||
|
"095c375025": "没有可用规则",
|
||||||
|
"ed36a1ef76": "任何",
|
||||||
|
"7dce122004": "编辑",
|
||||||
|
"efba20a02e": "没有可用数据",
|
||||||
|
"e0aa021e21": "确定",
|
||||||
|
"7d367dab8b": "源 IP",
|
||||||
|
"c050956ed2": "源端口",
|
||||||
|
"94386968c2": "目标 IP",
|
||||||
|
"64ae5dd047": "目标端口",
|
||||||
|
"fbb3878eb7": "提交防火墙策略?",
|
||||||
|
"1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
|
||||||
"867cee98fd": "密码不一致",
|
"867cee98fd": "密码不一致",
|
||||||
"9864ff9420": "请输入旧密码",
|
"9864ff9420": "请输入旧密码",
|
||||||
"14a714ab22": "请输入新密码",
|
"14a714ab22": "请输入新密码",
|
||||||
@@ -292,6 +330,8 @@
|
|||||||
"7a941a0f87": "更新 SSH 密钥",
|
"7a941a0f87": "更新 SSH 密钥",
|
||||||
"fdad65b7be": "强制 HTTP 传输",
|
"fdad65b7be": "强制 HTTP 传输",
|
||||||
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
|
||||||
|
"bda22ca687": "USB 检测增强",
|
||||||
|
"f7df928057": "在获取 USB 状态时也会检查 DISC 状态",
|
||||||
"021fa314ef": "USB 复用",
|
"021fa314ef": "USB 复用",
|
||||||
"5c43d74dbd": "控制 USB 复用状态",
|
"5c43d74dbd": "控制 USB 复用状态",
|
||||||
"f6c8ddbadf": "禁用 USB 复用",
|
"f6c8ddbadf": "禁用 USB 复用",
|
||||||
@@ -302,12 +342,21 @@
|
|||||||
"f5ddf02991": "重启系统",
|
"f5ddf02991": "重启系统",
|
||||||
"1dbbf194af": "重启设备系统",
|
"1dbbf194af": "重启设备系统",
|
||||||
"1de72c4fc6": "重启",
|
"1de72c4fc6": "重启",
|
||||||
|
"c887e5a479": "编辑配置",
|
||||||
|
"90b21a0536": "编辑原始配置文件",
|
||||||
"f43c0398a4": "重置配置",
|
"f43c0398a4": "重置配置",
|
||||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||||
"0d784092e8": "重置配置",
|
"0d784092e8": "重置配置",
|
||||||
"a776e925bf": "重启系统?",
|
"a776e925bf": "重启系统?",
|
||||||
"1f070051ff": "你确定重启系统吗?",
|
"1f070051ff": "你确定重启系统吗?",
|
||||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||||
|
"919ff9ff77": "编辑原始配置 JSON。小心修改,无效 JSON 可能导致系统问题。",
|
||||||
|
"4e11db406c": "保存中",
|
||||||
|
"f24a0236a3": "配置已保存",
|
||||||
|
"b032420c4b": "配置已成功保存。某些更改可能需要系统重启才能生效。",
|
||||||
|
"c8850fa947": "是否现在重启系统?",
|
||||||
|
"61057a0c84": "稍后",
|
||||||
|
"2d9c2140c5": "立刻重启",
|
||||||
"0db377921f": "常规",
|
"0db377921f": "常规",
|
||||||
"3845ee1693": "查看设备的版本信息",
|
"3845ee1693": "查看设备的版本信息",
|
||||||
"d721757161": "主题",
|
"d721757161": "主题",
|
||||||
@@ -372,6 +421,8 @@
|
|||||||
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
||||||
"cc172c234b": "更改 IPv4 模式?",
|
"cc172c234b": "更改 IPv4 模式?",
|
||||||
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
||||||
|
"0ddd0ae8fd": "更改 MAC 地址?",
|
||||||
|
"d0158ce3c1": "更改 MAC 地址可能会导致设备 IP 重新分配和更改。",
|
||||||
"34b6cd7517": "版本",
|
"34b6cd7517": "版本",
|
||||||
"a1937e8ac1": "检查系统和应用程序的版本",
|
"a1937e8ac1": "检查系统和应用程序的版本",
|
||||||
"04a115bdd8": "应用版本",
|
"04a115bdd8": "应用版本",
|
||||||
@@ -396,6 +447,7 @@
|
|||||||
"7c81553358": "再次检查",
|
"7c81553358": "再次检查",
|
||||||
"217b416896": "更新可用",
|
"217b416896": "更新可用",
|
||||||
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
|
||||||
|
"8977c3f0b7": "下载代理加速前缀",
|
||||||
"99b1054c0f": "立即更新",
|
"99b1054c0f": "立即更新",
|
||||||
"7f3cd4480d": "稍后更新",
|
"7f3cd4480d": "稍后更新",
|
||||||
"cffae9918d": "更新错误",
|
"cffae9918d": "更新错误",
|
||||||
@@ -413,7 +465,6 @@
|
|||||||
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
|
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
|
||||||
"ffbb410a55": "删除中",
|
"ffbb410a55": "删除中",
|
||||||
"3dd2e50646": "仅延迟",
|
"3dd2e50646": "仅延迟",
|
||||||
"7dce122004": "编辑",
|
|
||||||
"5fb63579fc": "复制",
|
"5fb63579fc": "复制",
|
||||||
"fc2839fcdf": "+ 添加新宏",
|
"fc2839fcdf": "+ 添加新宏",
|
||||||
"05fd7d5b9c": "您确定要删除",
|
"05fd7d5b9c": "您确定要删除",
|
||||||
@@ -436,6 +487,7 @@
|
|||||||
"21d104a54f": "处理中...",
|
"21d104a54f": "处理中...",
|
||||||
"9844086d90": "未检测到 SD 卡",
|
"9844086d90": "未检测到 SD 卡",
|
||||||
"b56e598918": "SD 卡挂载失败",
|
"b56e598918": "SD 卡挂载失败",
|
||||||
|
"16eb8ed6c8": "格式化 MicroSD 卡",
|
||||||
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
|
||||||
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
"1d50425f88": "管理 KVM 存储中的共享文件夹",
|
||||||
"74ff4bad28": "未找到文件",
|
"74ff4bad28": "未找到文件",
|
||||||
@@ -446,6 +498,8 @@
|
|||||||
"8bf8854beb": "共",
|
"8bf8854beb": "共",
|
||||||
"53e61336bb": "条结果",
|
"53e61336bb": "条结果",
|
||||||
"d1f5a81904": "卸载 MicroSD 卡",
|
"d1f5a81904": "卸载 MicroSD 卡",
|
||||||
|
"827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
|
||||||
|
"5874cf46ff": "SD 卡格式化成功",
|
||||||
"7dc25305d4": "视频编码类型",
|
"7dc25305d4": "视频编码类型",
|
||||||
"41d263a988": "视频质量",
|
"41d263a988": "视频质量",
|
||||||
"41f5283941": "NPU 应用",
|
"41f5283941": "NPU 应用",
|
||||||
|
|||||||
4
usb.go
4
usb.go
@@ -130,7 +130,7 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
|
|||||||
var usbState = "unknown"
|
var usbState = "unknown"
|
||||||
|
|
||||||
func rpcGetUSBState() (state string) {
|
func rpcGetUSBState() (state string) {
|
||||||
return gadget.GetUsbState()
|
return gadget.GetUsbState(config.UsbEnhancedDetection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerUSBStateUpdate() {
|
func triggerUSBStateUpdate() {
|
||||||
@@ -144,7 +144,7 @@ func triggerUSBStateUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkUSBState() {
|
func checkUSBState() {
|
||||||
newState := gadget.GetUsbState()
|
newState := gadget.GetUsbState(config.UsbEnhancedDetection)
|
||||||
if newState == usbState {
|
if newState == usbState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -781,6 +782,107 @@ func rpcUnmountSDStorage() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcFormatSDStorage(confirm bool) error {
|
||||||
|
if !confirm {
|
||||||
|
return fmt.Errorf("format not confirmed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1"); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("sd device not found: /dev/mmcblk1")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to stat sd device: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateMtp(false); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("failed to update mtp before formatting sd")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("mount").Output(); err == nil {
|
||||||
|
if strings.Contains(string(out), " on /mnt/sdcard") {
|
||||||
|
if umOut, umErr := exec.Command("umount", "/mnt/sdcard").CombinedOutput(); umErr != nil {
|
||||||
|
return fmt.Errorf("failed to unmount sdcard: %w: %s", umErr, strings.TrimSpace(string(umOut)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(SDImagesFolder, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure mount point: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
|
||||||
|
var lastErr error
|
||||||
|
if _, err := exec.LookPath("sfdisk"); err == nil {
|
||||||
|
sfdiskInput := "label: dos\nunit: sectors\n\n2048,,c,*\n"
|
||||||
|
cmd := exec.Command("sfdisk", "/dev/mmcblk1")
|
||||||
|
cmd.Stdin = bytes.NewBufferString(sfdiskInput)
|
||||||
|
partOut, partErr := cmd.CombinedOutput()
|
||||||
|
if partErr != nil {
|
||||||
|
lastErr = fmt.Errorf("sfdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else if _, err := exec.LookPath("fdisk"); err == nil {
|
||||||
|
fdiskScript := "o\nn\np\n1\n2048\n\nt\n1\nc\na\n1\nw\n"
|
||||||
|
cmd := exec.Command("fdisk", "/dev/mmcblk1")
|
||||||
|
cmd.Stdin = bytes.NewBufferString(fdiskScript)
|
||||||
|
partOut, partErr := cmd.CombinedOutput()
|
||||||
|
if partErr != nil {
|
||||||
|
lastErr = fmt.Errorf("fdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else if _, err := exec.LookPath("parted"); err == nil {
|
||||||
|
partedOut, partedErr := exec.Command("parted", "-s", "/dev/mmcblk1", "mklabel", "msdos", "mkpart", "primary", "fat32", "1MiB", "100%").CombinedOutput()
|
||||||
|
if partedErr != nil {
|
||||||
|
lastErr = fmt.Errorf("parted failed: %w: %s", partedErr, strings.TrimSpace(string(partedOut)))
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no partitioning tool found (need sfdisk, fdisk, or parted)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("failed to create sd partition: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("partprobe"); err == nil {
|
||||||
|
if _, err := exec.Command("partprobe", "/dev/mmcblk1").CombinedOutput(); err != nil {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
} else {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("sd partition not found after partitioning: /dev/mmcblk1p1")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to stat sd partition: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkfsOut, mkfsErr := exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1").CombinedOutput()
|
||||||
|
if mkfsErr != nil {
|
||||||
|
return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
|
||||||
|
}
|
||||||
|
|
||||||
|
mountOut, mountErr := exec.Command("mount", "/dev/mmcblk1p1", SDImagesFolder).CombinedOutput()
|
||||||
|
if mountErr != nil {
|
||||||
|
return fmt.Errorf("failed to mount sdcard after format: %w: %s", mountErr, strings.TrimSpace(string(mountOut)))
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncConfigSD(false)
|
||||||
|
|
||||||
|
if err := updateMtp(true); err != nil {
|
||||||
|
return fmt.Errorf("failed to update mtp after formatting sd: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcListSDStorageFiles() (*StorageFiles, error) {
|
func rpcListSDStorageFiles() (*StorageFiles, error) {
|
||||||
files, err := os.ReadDir(SDImagesFolder)
|
files, err := os.ReadDir(SDImagesFolder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -842,6 +944,7 @@ const (
|
|||||||
|
|
||||||
type SDMountStatusResponse struct {
|
type SDMountStatusResponse struct {
|
||||||
Status SDMountStatus `json:"status"`
|
Status SDMountStatus `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
||||||
@@ -849,9 +952,13 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
|||||||
return &SDMountStatusResponse{Status: SDMountNone}, nil
|
return &SDMountStatusResponse{Status: SDMountNone}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
|
||||||
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "no_partition"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
output, err := exec.Command("mount").Output()
|
output, err := exec.Command("mount").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_failed"}, fmt.Errorf("failed to check mount status: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
||||||
@@ -860,19 +967,19 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
|
|||||||
|
|
||||||
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
|
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to mount SD card: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_failed"}, fmt.Errorf("failed to mount SD card: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err = exec.Command("mount").Output()
|
output, err = exec.Command("mount").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status after mounting: %v", err)
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_after_failed"}, fmt.Errorf("failed to check mount status after mounting: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
|
||||||
return &SDMountStatusResponse{Status: SDMountOK}, nil
|
return &SDMountStatusResponse{Status: SDMountOK}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SDMountStatusResponse{Status: SDMountFail}, nil
|
return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_unknown"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type SDStorageFileUpload struct {
|
type SDStorageFileUpload struct {
|
||||||
@@ -984,21 +1091,13 @@ usb_max_packet_size 0x200
|
|||||||
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
|
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMtpWithSDStatus() error {
|
func updateMtp(withSD bool) error {
|
||||||
resp, _ := rpcGetSDMountStatus()
|
if err := writeUmtprdConfFile(withSD); err != nil {
|
||||||
if resp.Status == SDMountOK {
|
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
||||||
if err := writeUmtprdConfFile(true); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := writeUmtprdConfFile(false); err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to write umtprd conf file")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.UsbDevices.Mtp {
|
if config.UsbDevices.Mtp {
|
||||||
if err := gadget.UnbindUDCToDWC3(); err != nil {
|
if err := gadget.UnbindUDC(); err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to unbind UDC")
|
logger.Error().Err(err).Msg("failed to unbind gadget from UDC")
|
||||||
}
|
}
|
||||||
|
|
||||||
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
|
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
|
||||||
@@ -1013,10 +1112,29 @@ func updateMtpWithSDStatus() error {
|
|||||||
return fmt.Errorf("failed to exec binary: %w", err)
|
return fmt.Errorf("failed to exec binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rpcSetUsbDevices(*config.UsbDevices); err != nil {
|
var lastErr error
|
||||||
return fmt.Errorf("failed to set usb devices: %w", err)
|
for attempt := 0; attempt < 6; attempt++ {
|
||||||
|
if err := rpcSetUsbDevices(*config.UsbDevices); err == nil {
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
logger.Warn().
|
||||||
|
Int("attempt", attempt+1).
|
||||||
|
Err(err).
|
||||||
|
Msg("failed to re-apply usb devices after mtp update, retrying")
|
||||||
|
time.Sleep(time.Duration(300*(attempt+1)) * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("failed to set usb devices after mtp update: %w", lastErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateMtpWithSDStatus() error {
|
||||||
|
resp, _ := rpcGetSDMountStatus()
|
||||||
|
return updateMtp(resp.Status == SDMountOK)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user