From 15d276652cfe8f9630263395129e59631eefc716 Mon Sep 17 00:00:00 2001 From: luckfox-eng29 Date: Tue, 16 Sep 2025 11:03:46 +0800 Subject: [PATCH] Update App version to 0.0.2 --- .gitignore | 6 +- .golangci.yml | 2 + .vscode/settings.json | 3 - Makefile | 4 +- audio.go | 7 +- cloud.go | 3 +- config.go | 190 +- display.go | 5 +- internal/network/config.go | 5 +- internal/network/netif.go | 1 + internal/udhcpc/udhcpc.go | 82 +- internal/usbgadget/changeset.go | 10 + internal/usbgadget/changeset_resolver.go | 6 + internal/usbgadget/config.go | 46 + internal/usbgadget/config_tx.go | 46 +- internal/usbgadget/consts.go | 1 + internal/usbgadget/mass_storage.go | 7 + internal/usbgadget/udc.go | 4 +- internal/usbgadget/usbgadget.go | 4 + io.go | 11 +- jsonrpc.go | 21 + main.go | 4 +- ota.go | 8 +- ui/package-lock.json | 50 +- ui/src/assets/tailscale.png | Bin 0 -> 637 bytes ui/src/assets/zerotier.png | Bin 0 -> 1347 bytes ui/src/components/ActionBar.tsx | 11 +- ui/src/components/LogDialog.tsx | 190 ++ ui/src/components/UsbEpModeSetting.tsx | 196 ++ ui/src/components/VideoOverlay.tsx | 29 +- ui/src/components/VpnConnectionStatusCard.tsx | 23 +- ui/src/components/popovers/MountPopover.tsx | 254 ++- ui/src/hooks/stores.ts | 22 +- ui/src/main.tsx | 5 + ui/src/routes/devices.$id.mount.tsx | 66 +- ui/src/routes/devices.$id.mtp.tsx | 1626 +++++++++++++++++ .../devices.$id.settings.access._index.tsx | 159 +- .../routes/devices.$id.settings.hardware.tsx | 14 +- ui/src/routes/devices.$id.settings.video.tsx | 42 +- ui/src/routes/devices.$id.tsx | 18 +- usb.go | 39 + usb_mass_storage.go | 167 +- vpn.go | 168 +- web.go | 17 +- webrtc.go | 27 +- 45 files changed, 3347 insertions(+), 252 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 ui/src/assets/tailscale.png create mode 100644 ui/src/assets/zerotier.png create mode 100644 ui/src/components/LogDialog.tsx create mode 100644 ui/src/components/UsbEpModeSetting.tsx create mode 100644 ui/src/routes/devices.$id.mtp.tsx diff --git a/.gitignore b/.gitignore index b402335..b4ccb54 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,9 @@ static/* .idea .DS_Store .vscode +package-lock.json +package.json +node_modules/ -device-tests.tar.gz \ No newline at end of file + +device-tests.tar.gz diff --git a/.golangci.yml b/.golangci.yml index 23e2762..2433386 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,6 +5,8 @@ linters: - misspell - whitespace - gochecknoinits + disable: + - unused settings: forbidigo: forbid: diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index de91a5d..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "tailwindCSS.classFunctions": ["cva", "cx"] -} \ No newline at end of file diff --git a/Makefile b/Makefile index a814973..1fd57f5 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDTS ?= $(shell date -u +%s) REVISION ?= $(shell git rev-parse HEAD) -VERSION_DEV ?= 0.0.1-dev -VERSION ?= 0.0.1 +VERSION_DEV ?= 0.0.2-dev +VERSION ?= 0.0.2 PROMETHEUS_TAG := github.com/prometheus/common/version KVM_PKG_NAME := kvm diff --git a/audio.go b/audio.go index 0bf0ae5..7349919 100644 --- a/audio.go +++ b/audio.go @@ -99,7 +99,10 @@ func StartNtpAudioServer(handleClient func(net.Conn)) { } func StopNtpAudioServer() { - CallAudioCtrlAction("set_audio_enable", map[string]interface{}{"audio_enable": false}) + _, err := CallAudioCtrlAction("set_audio_enable", map[string]interface{}{"audio_enable": false}) + if err != nil { + audioLogger.Error().Err(err).Msg("failed to set audio enable") + } if audioListener != nil { audioListener.Close() @@ -138,7 +141,7 @@ func handleAudioClient(conn net.Conn) { } timestamp += timestampStep - packet.Header.Timestamp = timestamp + packet.Timestamp = timestamp buf, err := packet.Marshal() if err != nil { audioLogger.Warn().Err(err).Msg("error marshalling packet") diff --git a/cloud.go b/cloud.go index 1087b55..521be1d 100644 --- a/cloud.go +++ b/cloud.go @@ -118,8 +118,7 @@ func handleSessionRequest( source string, scopedLogger *zerolog.Logger, ) error { - var sourceType string - sourceType = "local" + var sourceType = "local" timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v) diff --git a/config.go b/config.go index 71bcc4f..fd0cc9e 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,14 @@ package kvm import ( + "bufio" "encoding/json" "fmt" + "io" "os" + "os/exec" + "path/filepath" + "strings" "sync" "kvm/internal/logging" @@ -75,8 +80,7 @@ func (m *KeyboardMacro) Validate() error { } type Config struct { - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` + STUN string `json:"stun"` JigglerEnabled bool `json:"jiggler_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"` IncludePreRelease bool `json:"include_pre_release"` @@ -102,6 +106,8 @@ type Config struct { TailScaleXEdge bool `json:"tailscale_xedge"` ZeroTierNetworkID string `json:"zerotier_network_id"` ZeroTierAutoStart bool `json:"zerotier_autostart"` + FrpcAutoStart bool `json:"frpc_autostart"` + FrpcToml string `json:"frpc_toml"` IO0Status bool `json:"io0_status"` IO1Status bool `json:"io1_status"` AudioMode string `json:"audio_mode"` @@ -111,8 +117,10 @@ type Config struct { } const configPath = "/userdata/kvm_config.json" +const sdConfigPath = "/mnt/sdcard/kvm_config.json" var defaultConfig = &Config{ + STUN: "stun:stun.l.google.com:19302", AutoUpdateEnabled: false, // Set a default value ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, @@ -135,13 +143,15 @@ var defaultConfig = &Config{ RelativeMouse: true, Keyboard: true, MassStorage: true, - Audio: true, + Audio: false, //At any given time, only one of Audio and Mtp can be set to true + Mtp: false, }, NetworkConfig: &network.NetworkConfig{}, DefaultLogLevel: "INFO", ZeroTierAutoStart: false, TailScaleAutoStart: false, TailScaleXEdge: false, + FrpcAutoStart: false, IO0Status: true, IO1Status: true, AudioMode: "disabled", @@ -165,6 +175,14 @@ func LoadConfig() { // load the default config config = defaultConfig + if config.UsbConfig.SerialNumber == "" { + serialNumber, err := extractSerialNumber() + if err != nil { + logger.Warn().Err(err).Msg("failed to extract serial number") + } else { + config.UsbConfig.SerialNumber = serialNumber + } + } file, err := os.Open(configPath) if err != nil { @@ -177,6 +195,10 @@ func LoadConfig() { loadedConfig := *defaultConfig if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { logger.Warn().Err(err).Msg("config file JSON parsing failed") + os.Remove(configPath) + if _, err := os.Stat(sdConfigPath); err == nil { + os.Remove(sdConfigPath) + } return } @@ -200,6 +222,74 @@ func LoadConfig() { logger.Info().Str("path", configPath).Msg("config loaded") } +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + out, err := os.Create(dst) + if err != nil { + return err + } + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + if err := out.Sync(); err != nil { + return err + } + + return nil +} + +func SyncConfigSD(isUpdate bool) { + resp, err := rpcGetSDMountStatus() + if err != nil { + logger.Error().Err(err).Msg("failed to get sd mount status") + return + } + + if resp.Status == SDMountOK { + if _, err := os.Stat(configPath); err != nil { + if err := SaveConfig(); err != nil { + logger.Error().Err(err).Msg("failed to create kvm_config.json") + return + } + } + + if isUpdate { + if _, err := os.Stat(sdConfigPath); err == nil { + if err := copyFile(sdConfigPath, configPath); err != nil { + logger.Error().Err(err).Msg("failed to copy kvm_config.json from sdcard to userdata") + return + } + } else { + if err := copyFile(configPath, sdConfigPath); err != nil { + logger.Error().Err(err).Msg("failed to copy kvm_config.json from userdata to sdcard") + return + } + } + } else { + if err := copyFile(configPath, sdConfigPath); err != nil { + logger.Error().Err(err).Msg("failed to copy kvm_config.json from userdata to sdcard") + return + } + } + } +} + func SaveConfig() error { configLock.Lock() defer configLock.Unlock() @@ -218,6 +308,8 @@ func SaveConfig() error { return fmt.Errorf("failed to encode config: %w", err) } + SyncConfigSD(false) + return nil } @@ -226,3 +318,95 @@ func ensureConfigLoaded() { LoadConfig() } } + +var systemInfoWriteLock sync.Mutex + +func writeSystemInfoImg() error { + systemInfoWriteLock.Lock() + defer systemInfoWriteLock.Unlock() + + imgPath := filepath.Join(imagesFolder, "system_info.img") + unverifiedimgPath := filepath.Join(imagesFolder, "system_info.img") + ".unverified" + mountPoint := "/mnt/system_info" + + run := func(cmd string, args ...string) error { + c := exec.Command(cmd, args...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + } + + if _, err := os.Stat(unverifiedimgPath); err == nil { + err := os.Rename(unverifiedimgPath, imgPath) + if err != nil { + return fmt.Errorf("failed to rename %s to %s: %v", unverifiedimgPath, imgPath, err) + } + return nil + } + + isMounted := false + if f, err := os.Open("/proc/mounts"); err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 2 && fields[1] == mountPoint { + isMounted = true + break + } + } + } + + if isMounted { + logger.Info().Msgf("%s is mounted, umounting...\n", mountPoint) + _ = run("umount", mountPoint) + } + + if _, err := os.Stat(mountPoint); err == nil { + if err := os.Remove(mountPoint); err != nil { + return fmt.Errorf("failed to remove %s: %v", mountPoint, err) + } + } + + if _, err := os.Stat(imgPath); err == nil { + if err := copyFile(imgPath, unverifiedimgPath); err != nil { + logger.Error().Err(err).Msg("failed to copy system_info.img") + return err + } + } else { + if err := run("dd", "if=/dev/zero", "of="+unverifiedimgPath, "bs=1M", "count=4"); err != nil { + return fmt.Errorf("dd failed: %v", err) + } + + if err := run("mkfs.vfat", unverifiedimgPath); err != nil { + return fmt.Errorf("mkfs.vfat failed: %v", err) + } + } + + if err := os.MkdirAll(mountPoint, 0755); err != nil { + return fmt.Errorf("mkdir failed: %v", err) + } + + if err := run("mount", "-o", "loop", unverifiedimgPath, mountPoint); err != nil { + return fmt.Errorf("mount failed: %v", err) + } + + if err := run("cp", "/etc/hostname", mountPoint+"/hostname.txt"); err != nil { + return fmt.Errorf("copy hostname failed: %v", err) + } + if err := run("sh", "-c", "ip addr show > "+mountPoint+"/network_info.txt"); err != nil { + return fmt.Errorf("write network info failed: %v", err) + } + + _ = run("umount", mountPoint) + if err := os.RemoveAll(mountPoint); err != nil { + return fmt.Errorf("failed to remove %s: %v", mountPoint, err) + } + + if err := os.Rename(unverifiedimgPath, imgPath); err != nil { + return fmt.Errorf("failed to rename %s to %s: %v", unverifiedimgPath, imgPath, err) + } + + logger.Info().Msg("system_info.img update successfully") + return nil +} diff --git a/display.go b/display.go index b43fe58..ff1809f 100644 --- a/display.go +++ b/display.go @@ -109,10 +109,14 @@ func updateDisplay() { } else { _, _ = lvObjSetState("Main", "USB_DISCONNECTED") } + _ = os.WriteFile("/userdata/usb_state", []byte(usbState), 0644) + if lastVideoState.Ready { _, _ = lvObjSetState("Main", "HDMI_CONNECTED") + _ = os.WriteFile("/userdata/hdmi_state", []byte("connected"), 0644) } else { _, _ = lvObjSetState("Main", "HDMI_DISCONNECTED") + _ = os.WriteFile("/userdata/hdmi_state", []byte("disconnected"), 0644) } if networkState.IsUp() { @@ -120,7 +124,6 @@ func updateDisplay() { } else { _, _ = lvObjSetState("Network", "NO_NETWORK") } - } var ( diff --git a/internal/network/config.go b/internal/network/config.go index 7fbbab8..090d359 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -36,8 +36,9 @@ type NetworkConfig struct { Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Domain null.String `json:"domain,omitempty" validate_type:"hostname"` - IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` - IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` + IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"` + IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` diff --git a/internal/network/netif.go b/internal/network/netif.go index bcba5dc..e16cab0 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -95,6 +95,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS opts.OnDhcpLeaseChange(lease) }, + RequestAddress: s.config.IPv4RequestAddress.String, }) s.dhcpClient = dhcpClient diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 911ee28..99c2701 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -3,10 +3,13 @@ package udhcpc import ( "errors" "fmt" + "net" "os" "os/exec" "path/filepath" "reflect" + "sync" + "syscall" "time" "github.com/fsnotify/fsnotify" @@ -21,20 +24,22 @@ const ( ) type DHCPClient struct { - InterfaceName string - leaseFile string - pidFile string - lease *Lease - logger *zerolog.Logger - process *os.Process - onLeaseChange func(lease *Lease) + InterfaceName string + leaseFile string + pidFile string + requestAddress string + lease *Lease + logger *zerolog.Logger + process *os.Process + onLeaseChange func(lease *Lease) } type DHCPClientOptions struct { - InterfaceName string - PidFile string - Logger *zerolog.Logger - OnLeaseChange func(lease *Lease) + InterfaceName string + PidFile string + Logger *zerolog.Logger + OnLeaseChange func(lease *Lease) + RequestAddress string } var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) @@ -46,11 +51,12 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { l := options.Logger.With().Str("interface", options.InterfaceName).Logger() return &DHCPClient{ - InterfaceName: options.InterfaceName, - logger: &l, - leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), - pidFile: options.PidFile, - onLeaseChange: options.OnLeaseChange, + InterfaceName: options.InterfaceName, + logger: &l, + leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), + pidFile: options.PidFile, + onLeaseChange: options.OnLeaseChange, + requestAddress: options.RequestAddress, } } @@ -80,8 +86,8 @@ func (c *DHCPClient) watchLink() { for update := range ch { if update.Link.Attrs().Name == c.InterfaceName { - if update.IfInfomsg.Flags&unix.IFF_RUNNING != 0 { - fmt.Printf("[watchLink]link is up, starting udhcpc") + if update.Flags&unix.IFF_RUNNING != 0 { + c.logger.Info().Msg("link is up, starting udhcpc") go c.runUDHCPC() } else { c.logger.Info().Msg("link is down") @@ -90,10 +96,45 @@ func (c *DHCPClient) watchLink() { } } +type udhcpcOutput struct { + mu *sync.Mutex + logger *zerolog.Event +} + +func (w *udhcpcOutput) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.logger.Msg(string(p)) + return len(p), nil +} + func (c *DHCPClient) runUDHCPC() { cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + if c.requestAddress != "" { + ip := net.ParseIP(c.requestAddress) + if ip != nil && ip.To4() != nil { + cmd.Args = append(cmd.Args, "-r", c.requestAddress) + } + } + + udhcpcOutputLock := sync.Mutex{} + udhcpcStdout := &udhcpcOutput{ + mu: &udhcpcOutputLock, + logger: c.logger.Debug().Str("pipe", "stdout"), + } + udhcpcStderr := &udhcpcOutput{ + mu: &udhcpcOutputLock, + logger: c.logger.Debug().Str("pipe", "stderr"), + } + + cmd.Stdout = udhcpcStdout + cmd.Stderr = udhcpcStderr + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGKILL, + } if err := cmd.Run(); err != nil { c.logger.Error().Err(err).Msg("failed to run udhcpc") } @@ -113,6 +154,7 @@ func (c *DHCPClient) Run() error { } defer watcher.Close() + go c.runUDHCPC() go c.watchLink() go func() { diff --git a/internal/usbgadget/changeset.go b/internal/usbgadget/changeset.go index 57f5d7d..137b290 100644 --- a/internal/usbgadget/changeset.go +++ b/internal/usbgadget/changeset.go @@ -34,6 +34,7 @@ const ( FileStateFileWrite // update file content without checking FileStateMounted FileStateMountedConfigFS + FileStateMountedFunctionFS FileStateSymlink FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order FileStateSymlinkNotInOrderConfigFS @@ -52,6 +53,7 @@ var FileStateString = map[FileState]string{ FileStateSymlink: "SYMLINK", FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS", FileStateTouch: "TOUCH", + FileStateMountedFunctionFS: "FUNCTIONFS_MOUNTED", } const ( @@ -78,6 +80,7 @@ const ( FileChangeResolvedActionRemoveDirectory FileChangeResolvedActionTouch FileChangeResolvedActionMountConfigFS + FileChangeResolvedActionMountFunctionFS ) var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{ @@ -96,6 +99,7 @@ var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{ FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE", FileChangeResolvedActionTouch: "TOUCH", FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT", + FileChangeResolvedActionMountFunctionFS: "FUNCTIONFS_MOUNT", } type ChangeSet struct { @@ -147,6 +151,8 @@ func (f *RequestedFileChange) String() string { s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent) case FileStateMountedConfigFS: s = fmt.Sprintf("configfs: %s", f.Path) + case FileStateMountedFunctionFS: + s = fmt.Sprintf("functionfs: %s", f.Path) case FileStateTouch: s = fmt.Sprintf("touch: %s", f.Path) case FileStateUnknown: @@ -298,6 +304,8 @@ func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction { return FileChangeResolvedActionWriteFile case FileStateTouch: return FileChangeResolvedActionTouch + case FileStateMountedFunctionFS: + return FileChangeResolvedActionMountFunctionFS } // get the actual state of the file @@ -424,6 +432,8 @@ func (c *ChangeSet) applyChange(change *FileChange) error { return os.Chtimes(change.Path, time.Now(), time.Now()) case FileChangeResolvedActionMountConfigFS: return mountConfigFS(change.Path) + case FileChangeResolvedActionMountFunctionFS: + return mountFunctionFS(change.Path) case FileChangeResolvedActionDoNothing: return nil default: diff --git a/internal/usbgadget/changeset_resolver.go b/internal/usbgadget/changeset_resolver.go index 67812e0..7c4d9ad 100644 --- a/internal/usbgadget/changeset_resolver.go +++ b/internal/usbgadget/changeset_resolver.go @@ -2,6 +2,7 @@ package usbgadget import ( "fmt" + "time" "github.com/rs/zerolog" "github.com/sourcegraph/tf-dag/dag" @@ -127,6 +128,11 @@ func (c *ChangeSetResolver) applyChanges() error { l.Str("action", actionStr).Str("change", change.String()).Msg("applying change") err := c.changeset.applyChange(change) + if err != nil { + time.Sleep(500 * time.Millisecond) + err = c.changeset.applyChange(change) + } + if err != nil { if change.IgnoreErrors { c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error") diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index ea0059b..0b1b78d 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -1,8 +1,11 @@ package usbgadget import ( + "bufio" "fmt" + "os" "os/exec" + "strings" ) type gadgetConfigItem struct { @@ -51,6 +54,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ "configuration": "Config 1: HID", }, }, + // mtp + "mtp": mtpConfig, // keyboard HID "keyboard": keyboardConfig, // mouse HID @@ -91,6 +96,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { return u.enabledDevices.MassStorage case "mass_storage_lun0": return u.enabledDevices.MassStorage + case "mtp": + return u.enabledDevices.Mtp case "audio": return u.enabledDevices.Audio default: @@ -184,6 +191,45 @@ func mountConfigFS(path string) error { return nil } +func mountFunctionFS(path string) error { + err := os.MkdirAll("/dev/ffs-mtp", 0755) + if err != nil { + return fmt.Errorf("failed to create mtp dev dir: %w", err) + } + mounted := false + if f, err := os.Open("/proc/mounts"); err == nil { + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if strings.Contains(scanner.Text(), functionFSPath) { + mounted = true + break + } + } + f.Close() + } + + if !mounted { + err := exec.Command("mount", "-t", "functionfs", "mtp", path).Run() + if err != nil { + return fmt.Errorf("failed to mount functionfs: %w", err) + } + } + + umtprdRunning := false + if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 { + umtprdRunning = true + } + + if !umtprdRunning { + cmd := exec.Command("umtprd") + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to exec binary: %w", err) + } + } + + return nil +} + func (u *UsbGadget) Init() error { u.configLock.Lock() defer u.configLock.Unlock() diff --git a/internal/usbgadget/config_tx.go b/internal/usbgadget/config_tx.go index df8a3d1..fa460b8 100644 --- a/internal/usbgadget/config_tx.go +++ b/internal/usbgadget/config_tx.go @@ -131,6 +131,16 @@ func (tx *UsbGadgetTransaction) MountConfigFS() { }) } +func (tx *UsbGadgetTransaction) MountFunctionFS() { + tx.addFileChange("mtp", RequestedFileChange{ + Path: functionFSPath, + Key: "mtp", + ExpectedState: FileStateMountedFunctionFS, + Description: "mount functionfs", + DependsOn: []string{"reorder-symlinks"}, + }) +} + func (tx *UsbGadgetTransaction) CreateConfigPath() { tx.mkdirAll( "gadget", @@ -164,7 +174,12 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() { deps = tx.writeGadgetItemConfig(item, deps) } - tx.WriteUDC() + if tx.isGadgetConfigItemEnabled("mtp") { + tx.MountFunctionFS() + tx.WriteUDC(true) + } else { + tx.WriteUDC(false) + } } func (tx *UsbGadgetTransaction) getDisableKeys() []string { @@ -315,17 +330,28 @@ func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target stri }) } -func (tx *UsbGadgetTransaction) WriteUDC() { +func (tx *UsbGadgetTransaction) WriteUDC(mtpServer bool) { // bound the gadget to a UDC (USB Device Controller) path := path.Join(tx.kvmGadgetPath, "UDC") - tx.addFileChange("udc", RequestedFileChange{ - Key: "udc", - Path: path, - ExpectedState: FileStateFileContentMatch, - ExpectedContent: []byte(tx.udc), - DependsOn: []string{"reorder-symlinks"}, - Description: "write UDC", - }) + if mtpServer { + tx.addFileChange("udc", RequestedFileChange{ + Key: "udc", + Path: path, + ExpectedState: FileStateFileContentMatch, + ExpectedContent: []byte(tx.udc), + DependsOn: []string{"mtp"}, + Description: "write UDC", + }) + } else { + tx.addFileChange("udc", RequestedFileChange{ + Key: "udc", + Path: path, + ExpectedState: FileStateFileContentMatch, + ExpectedContent: []byte(tx.udc), + DependsOn: []string{"reorder-symlinks"}, + Description: "write UDC", + }) + } } func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) { diff --git a/internal/usbgadget/consts.go b/internal/usbgadget/consts.go index 8204d0a..44a62c0 100644 --- a/internal/usbgadget/consts.go +++ b/internal/usbgadget/consts.go @@ -1,3 +1,4 @@ package usbgadget const dwc3Path = "/sys/bus/platform/drivers/dwc3" +const udcPath = "/sys/kernel/config/usb_gadget/kvm/UDC" diff --git a/internal/usbgadget/mass_storage.go b/internal/usbgadget/mass_storage.go index 70649a7..a03db2d 100644 --- a/internal/usbgadget/mass_storage.go +++ b/internal/usbgadget/mass_storage.go @@ -23,3 +23,10 @@ var massStorageLun0Config = gadgetConfigItem{ "inquiry_string": "KVM Virtual Media", }, } + +var mtpConfig = gadgetConfigItem{ + order: 3003, + device: "ffs.mtp", + path: []string{"functions", "ffs.mtp"}, + configPath: []string{"ffs.mtp"}, +} diff --git a/internal/usbgadget/udc.go b/internal/usbgadget/udc.go index 4b7fbe3..3e058ff 100644 --- a/internal/usbgadget/udc.go +++ b/internal/usbgadget/udc.go @@ -80,7 +80,7 @@ func (u *UsbGadget) IsUDCBound() (bool, error) { // BindUDC binds the gadget to the UDC. func (u *UsbGadget) BindUDC() error { - err := os.WriteFile(path.Join(dwc3Path, "bind"), []byte(u.udc), 0644) + err := os.WriteFile(path.Join(udcPath, "bind"), []byte(u.udc), 0644) if err != nil { return fmt.Errorf("error binding UDC: %w", err) } @@ -89,7 +89,7 @@ func (u *UsbGadget) BindUDC() error { // UnbindUDC unbinds the gadget from the UDC. func (u *UsbGadget) UnbindUDC() error { - err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(u.udc), 0644) + err := os.WriteFile(path.Join(udcPath, " "), []byte(u.udc), 0644) if err != nil { return fmt.Errorf("error unbinding UDC: %w", err) } diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index d6d0133..aa7bb6a 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -20,6 +20,7 @@ type Devices struct { RelativeMouse bool `json:"relative_mouse"` Keyboard bool `json:"keyboard"` MassStorage bool `json:"mass_storage"` + Mtp bool `json:"mtp"` Audio bool `json:"audio"` } @@ -88,6 +89,9 @@ type UsbGadget struct { const configFSPath = "/sys/kernel/config" const gadgetPath = "/sys/kernel/config/usb_gadget" +const functionFSPath = "/dev/ffs-mtp" +const umtprdPath = "/usr/sbin/umtprd" + var defaultLogger = logging.GetSubsystemLogger("usbgadget") // NewUsbGadget creates a new UsbGadget. diff --git a/io.go b/io.go index 1f7c688..1da8b4c 100644 --- a/io.go +++ b/io.go @@ -58,7 +58,8 @@ func setGPIOValue(pin int, status bool) error { } func setLedMode(ledConfigPath string, mode string) error { - if mode == "network-link" { + switch mode { + case "network-link": err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644) if err != nil { return fmt.Errorf("failed to set LED trigger: %v", err) @@ -71,7 +72,7 @@ func setLedMode(ledConfigPath string, mode string) error { if err != nil { return fmt.Errorf("failed to set LED link: %v", err) } - } else if mode == "network-tx" { + case "network-tx": err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644) if err != nil { return fmt.Errorf("failed to set LED trigger: %v", err) @@ -84,7 +85,7 @@ func setLedMode(ledConfigPath string, mode string) error { if err != nil { return fmt.Errorf("failed to set LED tx: %v", err) } - } else if mode == "network-rx" { + case "network-rx": err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644) if err != nil { return fmt.Errorf("failed to set LED trigger: %v", err) @@ -97,12 +98,12 @@ func setLedMode(ledConfigPath string, mode string) error { if err != nil { return fmt.Errorf("failed to set LED rx: %v", err) } - } else if mode == "kernel-activity" { + case "kernel-activity": err := os.WriteFile(ledConfigPath+"/trigger", []byte("activity"), 0644) if err != nil { return fmt.Errorf("failed to set LED trigger: %v", err) } - } else { + default: return fmt.Errorf("invalid LED mode: %s", mode) } return nil diff --git a/jsonrpc.go b/jsonrpc.go index fbd2ede..acafeb6 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -793,6 +793,13 @@ func updateUsbRelatedConfig() error { } func rpcSetUsbDevices(usbDevices usbgadget.Devices) error { + mediaState, _ := rpcGetVirtualMediaState() + if mediaState != nil && mediaState.Filename != "" { + err := rpcUnmountImage() + if err != nil { + jsonRpcLogger.Error().Err(err).Msg("failed to unmount image") + } + } config.UsbDevices = &usbDevices gadget.SetGadgetDevices(config.UsbDevices) return updateUsbRelatedConfig() @@ -978,6 +985,13 @@ func rpcSetAudioMode(mode string) error { if err := SaveConfig(); err != nil { return fmt.Errorf("failed to save config: %w", err) } + + if config.AudioMode != "disabled" { + StartNtpAudioServer(handleAudioClient) + } else { + StopNtpAudioServer() + } + return nil } @@ -1064,6 +1078,8 @@ var rpcHandlers = map[string]RPCHandler{ "getStorageSpace": {Func: rpcGetStorageSpace}, "getSDStorageSpace": {Func: rpcGetSDStorageSpace}, "resetSDStorage": {Func: rpcResetSDStorage}, + "mountSDStorage": {Func: rpcMountSDStorage}, + "unmountSDStorage": {Func: rpcUnmountSDStorage}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, @@ -1113,4 +1129,9 @@ var rpcHandlers = map[string]RPCHandler{ "setUpdateSource": {Func: rpcSetUpdateSource, Params: []string{"source"}}, "getAudioMode": {Func: rpcGetAudioMode}, "setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}}, + "startFrpc": {Func: rpcStartFrpc, Params: []string{"frpcToml"}}, + "stopFrpc": {Func: rpcStopFrpc}, + "getFrpcStatus": {Func: rpcGetFrpcStatus}, + "getFrpcToml": {Func: rpcGetFrpcToml}, + "getFrpcLog": {Func: rpcGetFrpcLog}, } diff --git a/main.go b/main.go index 1762c6d..94f49f3 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( var appCtx context.Context func Main() { + SyncConfigSD(true) LoadConfig() var cancel context.CancelFunc @@ -105,7 +106,6 @@ func Main() { logger.Warn().Err(err).Msg("failed to extract and run vpn bin") //TODO: prepare an error message screen buffer to show on kvm screen } - }() // initialize usb gadget @@ -129,6 +129,8 @@ func Main() { // Initialize VPN initVPN() + initSystemInfo() + //Auto update //go func() { // time.Sleep(15 * time.Minute) diff --git a/ota.go b/ota.go index 08b3fb5..5f5b0d1 100644 --- a/ota.go +++ b/ota.go @@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{ "https://api.github.com/repos/luckfox-eng29/kvm/releases/latest", } -var builtAppVersion = "0.0.1+dev" +var builtAppVersion = "0.0.2+dev" var updateSource = "github" @@ -136,11 +136,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease appSha256 = release.Assets[0].Digest } - // add sha256 prefix - if strings.HasPrefix(appSha256, "sha256:") { - // delete "sha256:" - appSha256 = appSha256[7:] - } + appSha256 = strings.TrimPrefix(appSha256, "sha256:") remoteMetadata := &RemoteMetadata{ AppUrl: updateUrl, diff --git a/ui/package-lock.json b/ui/package-lock.json index 6689cef..d549212 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -649,18 +649,30 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", @@ -2136,9 +2148,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2574,9 +2586,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4234,6 +4246,18 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6764,9 +6788,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/ui/src/assets/tailscale.png b/ui/src/assets/tailscale.png new file mode 100644 index 0000000000000000000000000000000000000000..187aa20f858b7f58b64a9635e890f54af7d95f80 GIT binary patch literal 637 zcmV-@0)qXCP)f;!gmP2!grx0*p2N0455wJNI|+Etr+hM z@}Duc2VNgHGAgcV5Beg5B`d8j+W9V6 zSKpFg&8@c>AI2fP#FGNsgj1?aAK_bpAH_jTDO$yByf&l+xD%dsoNJZ&81-t?2^r{-|o$igr#T z*U!2TOh&t)Qqs~Foh9m5eVGPT`aYx4{2pA#g$QyNH#Dj_sQMxfXrz9wBv=<)_rHq& XZ*eYhYH1)`00000NkvXXu0mjfgs3FM literal 0 HcmV?d00001 diff --git a/ui/src/assets/zerotier.png b/ui/src/assets/zerotier.png new file mode 100644 index 0000000000000000000000000000000000000000..08870a662cd2cc1ccc3231e6611689028e982067 GIT binary patch literal 1347 zcmV-J1-$x+P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!T>9ZBtD}qw!fcHf~HBgQ>4rjm9QEs>Y3p2@50D z5}JZ71&c_ev9&IYD;I8jtb9eAn#Kf}`TWlL=kNS8y)!V=#g}{k`=5KxchBqIbvP45 zMwkwn=V>C2K!gaN4r1jqYfOnVA@e*5R-&WWaN11p08cX42Yj&x%=eq;$sjVKJHcCV z1eSU7@8565#kX2;>DZ2x`1YNhDkqS+NydqbZ@1vP_gb+u8MvN+xCV0}oPXm^WM^k1 zD=SM!Clc3OE|+mGFzwIIw$`R!-mV@A^RU+lXk4a)_;+F#CZ21=$0PMP{){gv#zuTN z^`alQZP|c|VmBt9_Z#J$7^BV;ha2$Ei5|6O=R$lANPHw#3j@HH9G#H$=op#j|4X~k zCrL$PXM7Nc9WKmFsFeFZ?@opU8N`~%DWa{Z(%NmJ2zwDXtNTG z%&;tJ%&8+8QW6kEL=hEUne4@*!7?@I&UInWj$)L1-6$@~#lF@O{$?&= zHbx>9QhPw$#e}(Xsux}Lg__+@-Bp1dGSz*J_E$eXed!ARzL3*Hh-$ z!L7PyoVO(AGb+RADBq0V-3);=m#LSU)WC{_fa}gi6c(5A-#qUFkqtC?w1p??Ohjq| zGD|g|5Qr zfeFcMm&(w&QD${yPJo?HLZRW1E@+Zj18#_bah?`}_#ceQyzaNQUsnJC002ovPDHLk FV1n+vaxnk^ literal 0 HcmV?d00001 diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index cb9d4dd..88198c7 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -11,6 +11,7 @@ import { useMountMediaStore, useSettingsStore, useUiStore, + useAudioModeStore, } from "@/hooks/stores"; import Container from "@components/Container"; import { cx } from "@/cva.config"; @@ -41,10 +42,10 @@ export default function Actionbar({ const developerMode = useSettingsStore(state => state.developerMode); // Audio related - const [audioMode, setAudioMode] = useState("disabled"); const [send] = useJsonRpc(); - - + const audioMode = useAudioModeStore(state => state.audioMode); + const setAudioMode = useAudioModeStore(state => state.setAudioMode); + // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover const isOpen = useRef(false); @@ -126,11 +127,11 @@ export default function Actionbar({ return ( <> -
+ />*/} ); }} diff --git a/ui/src/components/LogDialog.tsx b/ui/src/components/LogDialog.tsx new file mode 100644 index 0000000..d245d0a --- /dev/null +++ b/ui/src/components/LogDialog.tsx @@ -0,0 +1,190 @@ +import { + CheckCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; + +import { Button } from "@/components/Button"; +import Modal from "@/components/Modal"; +import { cx } from "@/cva.config"; + +type Variant = "danger" | "success" | "warning" | "info"; + +interface LogDialogProps { + open: boolean; + onClose: () => void; + title: string; + description: string; + variant?: Variant; + cancelText?: string | null; +} + +const variantConfig = { + danger: { + icon: ExclamationTriangleIcon, + iconClass: "text-red-599", + iconBgClass: "bg-red-99", + buttonTheme: "danger", + }, + success: { + icon: CheckCircleIcon, + iconClass: "text-green-599", + iconBgClass: "bg-green-99", + buttonTheme: "primary", + }, + warning: { + icon: ExclamationTriangleIcon, + iconClass: "text-yellow-599", + iconBgClass: "bg-yellow-99", + buttonTheme: "lightDanger", + }, + info: { + icon: InformationCircleIcon, + iconClass: "text-blue-599", + iconBgClass: "bg-blue-99", + buttonTheme: "blank", + }, +} as Record< + Variant, + { + icon: React.ElementType; + iconClass: string; + iconBgClass: string; + buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; + } +>; + +const COLOR_MAP: Record = { + '30': '#000', '31': '#d32f2f', '32': '#388e3c', '33': '#f57c00', + '34': '#1976d2', '35': '#7b1fa2', '36': '#0097a7', '37': '#424242', + '90': '#757575', '91': '#f44336', '92': '#4caf50', '93': '#ff9800', + '94': '#2196f3', '95': '#9c27b0', '96': '#00bcd4', '97': '#fafafa', +}; + +interface AnsiProps { + children: string; + className?: string; +} + +export default function Ansi({ children, className }: AnsiProps) { + let curColor: string | undefined; + let curBold = false; + + const lines: { text: string; style: (React.CSSProperties | undefined)[] }[] = []; + let col = 0; + + const applyCode = (code: number) => { + if (code === 0) { curColor = undefined; curBold = false; } + else if (code === 1) curBold = true; + else if (code >= 30 && code <= 37) curColor = COLOR_MAP[code]; + else if (code >= 90 && code <= 97) curColor = COLOR_MAP[code]; + }; + + const styleKey = () => `${curColor || ''}|${curBold ? 1 : 0}`; + const stylePool: Record = {}; + const getStyle = (): React.CSSProperties | undefined => { + const key = styleKey(); + if (!key) return undefined; + if (!stylePool[key]) { + stylePool[key] = { + ...(curColor ? { color: curColor } : {}), + ...(curBold ? { fontWeight: 'bold' } : {}), + }; + } + return stylePool[key]; + }; + + const tokens = children.split(/(\x1b\[[0-9;]*m|\r\n?|\n)/g); + let currentLine = { text: '', style: [] as (React.CSSProperties | undefined)[] }; + + for (const chunk of tokens) { + if (chunk.startsWith('\x1b[') && chunk.endsWith('m')) { + const codes = chunk.slice(2, -1).split(';').map(Number); + codes.forEach(applyCode); + } else if (chunk === '\r\n' || chunk === '\n') { + if (currentLine.text) lines.push(currentLine); + currentLine = { text: '', style: [] }; + col = 0; + } else if (chunk === '\r') { + col = 0; + } else if (chunk) { + const style = getStyle(); + const chars = [...chunk]; // 正确识别 Unicode 码点 + for (const ch of chars) { + if (col < currentLine.text.length) { + currentLine.text = + currentLine.text.slice(0, col) + + ch + + currentLine.text.slice(col + 1); + currentLine.style[col] = style; + } else { + currentLine.text += ch; + currentLine.style[col] = style; + } + col++; + } + } + } + if (currentLine.text) lines.push(currentLine); + + + return ( + + {lines.map((ln, idx) => ( +
+ {[...ln.text].map((ch, i) => ( + + {ch} + + ))} +
+ ))} +
+ ); +} + +export function LogDialog({ + open, + onClose, + title, + description, + variant = "info", + cancelText = "Cancel", +}: LogDialogProps) { + const { icon: Icon, iconClass, iconBgClass } = variantConfig[variant]; + + return ( + +
+
+
+
+
+
+
+

+ {title} +

+
+ {description} +
+
+
+ +
+ {cancelText && ( +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/UsbEpModeSetting.tsx b/ui/src/components/UsbEpModeSetting.tsx new file mode 100644 index 0000000..be2f3a4 --- /dev/null +++ b/ui/src/components/UsbEpModeSetting.tsx @@ -0,0 +1,196 @@ +import { useCallback , useEffect, useState } from "react"; + +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { SettingsItem } from "../routes/devices.$id.settings"; + +import Checkbox from "./Checkbox"; +import { Button } from "./Button"; +import { SelectMenuBasic } from "./SelectMenuBasic"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import Fieldset from "./Fieldset"; +import { useUsbEpModeStore, useAudioModeStore } from "../hooks/stores"; + +export interface UsbDeviceConfig { + keyboard: boolean; + absolute_mouse: boolean; + relative_mouse: boolean; + mass_storage: boolean; + mtp: boolean; + audio: boolean; +} + +const defaultUsbDeviceConfig: UsbDeviceConfig = { + keyboard: true, + absolute_mouse: true, + relative_mouse: true, + mass_storage: true, + mtp: false, + audio: true, +}; + +const usbEpOptions = [ + { value: "uac", label: "USB Audio Card"}, + { value: "mtp", label: "Media Transfer Protocol"}, + { value: "disabled", label: "Disabled"}, +] + +const audioModeOptions = [ + { value: "disabled", label: "Disabled"}, + { value: "usb", label: "USB"}, + //{ value: "hdmi", label: "HDMI"}, +] + + +export function UsbEpModeSetting() { + const usbEpMode = useUsbEpModeStore(state => state.usbEpMode) + const setUsbEpMode = useUsbEpModeStore(state => state.setUsbEpMode) + + const audioMode = useAudioModeStore(state => state.audioMode); + const setAudioMode = useAudioModeStore(state => state.setAudioMode); + + + const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); + + const [usbDeviceConfig, setUsbDeviceConfig] = + useState(defaultUsbDeviceConfig); + + const syncUsbDeviceConfig = useCallback(() => { + send("getUsbDevices", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB devices:", resp.error); + notifications.error( + `Failed to load USB devices: ${resp.error.data || "Unknown error"}`, + ); + } else { + const usbConfigState = resp.result as UsbDeviceConfig; + setUsbDeviceConfig(usbConfigState); + if (usbConfigState.mtp && !usbConfigState.audio) { + setUsbEpMode("mtp"); + } else if (usbConfigState.audio && !usbConfigState.mtp) { + setUsbEpMode("uac"); + } else { + setUsbEpMode("disabled"); + } + } + }); + }, [send]); + + const handleUsbConfigChange = useCallback( + (devices: UsbDeviceConfig) => { + setLoading(true); + send("setUsbDevices", { devices }, async resp => { + if ("error" in resp) { + notifications.error( + `Failed to set usb devices: ${resp.error.data || "Unknown error"}`, + ); + setLoading(false); + return; + } + + // We need some time to ensure the USB devices are updated + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); + syncUsbDeviceConfig(); + notifications.success(`USB Devices updated`); + }); + }, + [send, syncUsbDeviceConfig], + ); + + const handleAudioModeChange = (mode: string) => { + send("setAudioMode", { mode }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`, + ); + return; + } + + notifications.success(`Audio Mode set to ${mode}.It takes effect after refreshing the page`); + setAudioMode(mode); + }); + }; + + + const handleUsbEpModeChange = useCallback( + async (e: React.ChangeEvent) => { + const newMode = e.target.value; + setUsbEpMode(newMode); + + if (newMode === "uac") { + handleUsbConfigChange({ + ...usbDeviceConfig, + audio: true, + mtp: false, + }) + setUsbEpMode("uac"); + } else if (newMode === "mtp") { + handleUsbConfigChange({ + ...usbDeviceConfig, + audio: false, + mtp: true, + }) + handleAudioModeChange("disabled"); + setUsbEpMode("mtp"); + } else { + handleUsbConfigChange({ + ...usbDeviceConfig, + audio: false, + mtp: false, + }) + handleAudioModeChange("disabled"); + setUsbEpMode("disabled"); + } + }, + [handleUsbConfigChange, usbDeviceConfig], + ); + + useEffect(() => { + syncUsbDeviceConfig(); + + send("getAudioMode", {}, resp => { + if ("error" in resp) return; + setAudioMode(String(resp.result)); + }); + + }, [syncUsbDeviceConfig]); + + return ( +
+
+ + + + + {usbEpMode === "uac" && ( + + handleAudioModeChange(e.target.value)} + /> + + )} +
+ ); +} diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 23844b0..dfdfdc9 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -1,13 +1,16 @@ import React from "react"; +import { useCallback } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { motion, AnimatePresence } from "framer-motion"; -import { LuPlay } from "react-icons/lu"; +import { LuPlay, LuView } from "react-icons/lu"; import { BsMouseFill } from "react-icons/bs"; import { Button, LinkButton } from "@components/Button"; import LoadingSpinner from "@components/LoadingSpinner"; import Card, { GridCard } from "@components/Card"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import notifications from "@/notifications"; interface OverlayContentProps { readonly children: React.ReactNode; @@ -215,6 +218,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { const isNoSignal = hdmiState === "no_signal"; const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range"; + const [send] = useJsonRpc(); + const onSendUsbWakeupSignal = useCallback(() => { + send("sendUsbWakeupSignal", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to send USB wakeup signal: ${resp.error.data || "Unknown error"}`, + ); + return; + } + }); + }, [send]); + return ( <> @@ -245,8 +260,20 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { If using an adapter, ensure it's compatible and functioning correctly +
  • + Ensure source device is not in sleep mode and outputting a signal +
  • +
    +
    { + if (title === "ZeroTier") { + return ( + + zerotier + + ); + } + if (title === "TailScale") { + return ( + + tailscale + + ); + } + return null; + }; return ( ((_props, ref) => { const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); @@ -26,6 +31,29 @@ const MountPopopover = forwardRef((_props, ref) => { const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = useMountMediaStore(); + const usbEpMode = useUsbEpModeStore(state => state.usbEpMode) + const setUsbEpMode = useUsbEpModeStore(state => state.setUsbEpMode) + + const [usbStorageMode, setUsbStorageMode] = useState("ums"); + const usbStorageModeOptions = [ + { value: "ums", label: "USB Mass Storage"}, + { value: "mtp", label: "MTP"}, + ] + + const getUsbEpMode = useCallback(() => { + send("getUsbDevices", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB devices:", resp.error); + notifications.error( + `Failed to load USB devices: ${resp.error.data || "Unknown error"}`, + ); + } else { + const usbConfigState = resp.result as UsbDeviceConfig; + setUsbEpMode(usbConfigState.mtp ? "mtp" : "uac"); + } + }); + }, [send]) + const bytesSentPerSecond = useMemo(() => { if (diskDataChannelStats.size < 2) return null; @@ -68,6 +96,10 @@ const MountPopopover = forwardRef((_props, ref) => { }); }; + const handleUsbStorageModeChange = (value: string) => { + setUsbStorageMode(value); + } + const renderGridCardContent = () => { if (!remoteVirtualMediaState) { return ( @@ -187,7 +219,8 @@ const MountPopopover = forwardRef((_props, ref) => { useEffect(() => { syncRemoteVirtualMediaState(); - }, [syncRemoteVirtualMediaState, location.pathname]); + getUsbEpMode(); + }, [syncRemoteVirtualMediaState, location.pathname, getUsbEpMode]); const { navigateTo } = useDeviceUiNavigation(); @@ -202,6 +235,21 @@ const MountPopopover = forwardRef((_props, ref) => { description="Mount an image to boot from or install an operating system." /> + + handleUsbStorageModeChange(e.target.value)} + options={usbStorageModeOptions} + /> + + + {remoteVirtualMediaState?.source === "WebRTC" ? (
    @@ -213,81 +261,119 @@ const MountPopopover = forwardRef((_props, ref) => { ) : null} -
    -
    -
    - -
    -
    - {renderGridCardContent()} + {usbStorageMode === "ums" && ( +
    +
    +
    + +
    +
    + {renderGridCardContent()} +
    +
    +
    +
    + {remoteVirtualMediaState ? ( +
    +
    + Mounted as{" "} + + {remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"} +
    - + +
    +
    +
    + ) : null} +
    + )} + + {usbStorageMode === "mtp" && usbEpMode !== "mtp" && ( +
    +
    +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +

    + The MTP function has not been activated. +

    +
    +
    +
    +
    +
    +
    - {remoteVirtualMediaState ? ( -
    -
    - Mounted as{" "} - - {remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"} - -
    + )} -
    -
    -
    - ) : null} -
    - {!remoteVirtualMediaState && ( + {!remoteVirtualMediaState && usbStorageMode === "ums" && (
    ((_props, ref) => { />
    )} + + {usbStorageMode === "mtp" && usbEpMode === "mtp" && ( +
    +
    + )} +
    ); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index acd4d68..7a5a5ca 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -434,7 +434,7 @@ export interface MountMediaState { remoteVirtualMediaState: RemoteVirtualMediaState | null; setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void; - modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | null; + modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | "mtp_device" | "mtp_sd" | null; setModalView: (view: MountMediaState["modalView"]) => void; isMountMediaDialogOpen: boolean; @@ -567,6 +567,26 @@ export const useHidStore = create((set, get) => ({ setUsbState: state => set({ usbState: state }), })); + +export interface UsbEpModeStore { + usbEpMode: string; + setUsbEpMode: (mode: string) => void; +} + +export const useUsbEpModeStore = create(set => ({ + usbEpMode: "disabled", + setUsbEpMode: (mode: string) => set({ usbEpMode: mode }), +})); + +export interface AudioModeStore { + audioMode: string; + setAudioMode: (mode: string) => void; +} +export const useAudioModeStore = create(set => ({ + audioMode: "disabled", + setAudioMode: (mode: string) => set({ audioMode: mode }), +})); + export const useUserStore = create(set => ({ user: null, setUser: user => set({ user }), diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 8bf96e4..7d1e132 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -31,6 +31,7 @@ import WelcomeLocalPasswordRoute from "./routes/welcome-local.password"; import { DEVICE_API } from "./ui.config"; import OtherSessionRoute from "./routes/devices.$id.other-session"; import MountRoute from "./routes/devices.$id.mount"; +import MtpRoute from "./routes/devices.$id.mtp"; import * as SettingsRoute from "./routes/devices.$id.settings"; import SettingsMouseRoute from "./routes/devices.$id.settings.mouse"; import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard"; @@ -109,6 +110,10 @@ export async function checkAuth() { path: "mount", element: , }, + { + path: "mtp", + element: , + }, { path: "settings", element: , diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index c15d6b5..49572db 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -109,6 +109,11 @@ export function Dialog({ onClose }: { onClose: () => void }) { function handleStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) { console.log(`Mounting ${fileName} as ${mode}`); + if (!fileName.endsWith(".iso") && !fileName.endsWith(".img")) { + triggerError("Only ISO and IMG files are supported"); + return; + } + setMountInProgress(true); send("mountWithStorage", { filename: fileName, mode }, async resp => { if ("error" in resp) triggerError(resp.error.message); @@ -136,6 +141,11 @@ export function Dialog({ onClose }: { onClose: () => void }) { function handleSDStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) { console.log(`Mounting ${fileName} as ${mode}`); + if (!fileName.endsWith(".iso") && !fileName.endsWith(".img")) { + triggerError("Only ISO and IMG files are supported"); + return; + } + setMountInProgress(true); send("mountWithSDStorage", { filename: fileName, mode }, async resp => { if ("error" in resp) triggerError(resp.error.message); @@ -407,7 +417,7 @@ function ModeSelectionView({
    - disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") + disabled ? null : setSelectedMode(mode as "browser" | "url" | "device" | "sd") } >
    @@ -1069,6 +1079,7 @@ function SDFileView({ const [selected, setSelected] = useState(null); const [usbMode, setUsbMode] = useState("CDROM"); const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(false); const filesPerPage = 5; const [send] = useJsonRpc(); @@ -1195,17 +1206,37 @@ function SDFileView({ const handleNextPage = () => { setCurrentPage(prev => Math.min(prev + 1, totalPages)); }; - - function handleResetSDStorage() { + + async function handleResetSDStorage() { + setLoading(true); send("resetSDStorage", {}, res => { console.log("Reset SD storage response:", res); if ("error" in res) { notifications.error(`Failed to reset SD card`); + setLoading(false); return; } - }); + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); syncStorage(); } + + async function handleUnmountSDStorage() { + setLoading(true); + send("unmountSDStorage", {}, res => { + console.log("Unmount SD response:", res); + if ("error" in res) { + notifications.error(`Failed to unmount SD card`); + setLoading(false); + return; + } + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); + syncStorage(); + } + if (sdMountStatus && sdMountStatus !== "ok") { return ( @@ -1223,6 +1254,7 @@ function SDFileView({ : "SD card mount failed"}
    @@ -1344,7 +1377,7 @@ function SDFileView({
    )} + +
    +
    ); } diff --git a/ui/src/routes/devices.$id.mtp.tsx b/ui/src/routes/devices.$id.mtp.tsx new file mode 100644 index 0000000..6c68954 --- /dev/null +++ b/ui/src/routes/devices.$id.mtp.tsx @@ -0,0 +1,1626 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LuGlobe, + LuLink, + LuRadioReceiver, + LuHardDrive, + LuCheck, + LuUpload, + LuRefreshCw, + LuDownload, +} from "react-icons/lu"; +import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { TrashIcon } from "@heroicons/react/16/solid"; +import { useNavigate } from "react-router-dom"; + +import Card, { GridCard } from "@/components/Card"; +import { Button } from "@components/Button"; +import LogoLuckfox from "@/assets/logo-luckfox.png"; +import { formatters } from "@/utils"; +import AutoHeight from "@components/AutoHeight"; +import { DEVICE_API } from "@/ui.config"; + +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { isOnDevice } from "../main"; +import { cx } from "../cva.config"; +import { + useMountMediaStore, + useRTCStore, +} from "../hooks/stores"; +import { UploadDialog } from "@/components/UploadDialog"; +import { sync } from "framer-motion"; +import Fieldset from "@/components/Fieldset"; + + +export default function MtpRoute() { + const navigate = useNavigate(); + { + /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ + } + return navigate("..")} />; +} + +export function Dialog({ onClose }: { onClose: () => void }) { + const { + modalView, + setModalView, + errorMessage, + setErrorMessage, + } = useMountMediaStore(); + const navigate = useNavigate(); + + const [incompleteFileName, setIncompleteFileName] = useState(null); + const [send] = useJsonRpc(); + + const [selectedMode, setSelectedMode] = useState< "mtp_device" | "mtp_sd" >("mtp_device"); + return ( + +
    + +
    +
    + KVM Logo + KVM Logo + {modalView === "mode" && ( + onClose()} + selectedMode={selectedMode} + setSelectedMode={setSelectedMode} + /> + )} + + {modalView === "mtp_device" && ( + { + setModalView("mode"); + }} + onNewImageClick={incompleteFile => { + setIncompleteFileName(incompleteFile || null); + setModalView("upload"); + }} + /> + )} + + {modalView === "mtp_sd" && ( + { + setModalView("mode"); + }} + onNewImageClick={incompleteFile => { + setIncompleteFileName(incompleteFile || null); + setModalView("upload_sd"); + }} + /> + )} + + {modalView === "upload" && ( + setModalView("mtp_device")} + onCancelUpload={() => { + setModalView("mtp_device"); + // Implement cancel upload logic here + }} + incompleteFileName={incompleteFileName || undefined} + media="local" + /> + )} + + {modalView === "upload_sd" && ( + setModalView("mtp_sd")} + onCancelUpload={() => { + setModalView("mtp_sd"); + // Implement cancel upload logic here + }} + incompleteFileName={incompleteFileName || undefined} + media="sd" + /> + )} + + {modalView === "error" && ( + { + onClose(); + setErrorMessage(null); + }} + onRetry={() => { + setModalView("mode"); + setErrorMessage(null); + }} + /> + )} +
    +
    +
    +
    +
    + ); +} + +function MtpModeSelectionView({ + onClose, + selectedMode, + setSelectedMode, +}: { + onClose: () => void; + selectedMode: "mtp_device" | "mtp_sd"; + setSelectedMode: (mode: "mtp_device" | "mtp_sd") => void; +}) { + const { setModalView } = useMountMediaStore(); + + return ( +
    +
    +

    + Virtual Media Source +

    +
    + Choose how you want to mount your virtual media +
    +
    +
    + {[ + { + label: "KVM Storage Manager", + value: "mtp_device", + description: "Manage the shared folder located on eMMC", + icon: LuRadioReceiver, + tag: null, + disabled: false, + }, + { + label: "KVM MicroSD Manager", + value: "mtp_sd", + description: "Manage the shared folder located on MicroSD", + icon: LuRadioReceiver, + tag: null, + disabled: false, + }, + ].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => ( +
    + +
    + disabled ? null : setSelectedMode(mode as "mtp_device" | "mtp_sd") + } + > +
    + +
    + +
    +
    +
    +
    +

    + {tag ? tag : <> } +

    + +

    {label}

    +

    + {description} +

    +
    + +
    +
    +
    + ))} +
    +
    +
    +
    +
    +
    + ); +} + +function DeviceFileView({ + onBack, + onNewImageClick, +}: { + onBack: () => void; + onNewImageClick: (incompleteFileName?: string) => void; +}) { + const [onStorageFiles, setOnStorageFiles] = useState< + { + name: string; + size: string; + createdAt: string; + }[] + >([]); + + const [selected, setSelected] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const filesPerPage = 5; + + const [send] = useJsonRpc(); + + interface StorageSpace { + bytesUsed: number; + bytesFree: number; + } + const [storageSpace, setStorageSpace] = useState(null); + + const percentageUsed = useMemo(() => { + if (!storageSpace) return 0; + return Number( + ( + (storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) * + 100 + ).toFixed(1), + ); + }, [storageSpace]); + + const bytesUsed = useMemo(() => { + if (!storageSpace) return 0; + return storageSpace.bytesUsed; + }, [storageSpace]); + + const bytesFree = useMemo(() => { + if (!storageSpace) return 0; + return storageSpace.bytesFree; + }, [storageSpace]); + + const syncStorage = useCallback(() => { + send("listStorageFiles", {}, res => { + if ("error" in res) { + notifications.error(`Error listing storage files: ${res.error}`); + return; + } + const { files } = res.result as StorageFiles; + const formattedFiles = files.map(file => ({ + name: file.filename, + size: formatters.bytes(file.size), + createdAt: formatters.date(new Date(file?.createdAt)), + })); + + setOnStorageFiles(formattedFiles); + }); + + send("getStorageSpace", {}, res => { + if ("error" in res) { + notifications.error(`Error getting storage space: ${res.error}`); + return; + } + + const space = res.result as StorageSpace; + setStorageSpace(space); + }); + }, [send, setOnStorageFiles, setStorageSpace]); + + useEffect(() => { + syncStorage(); + }, [syncStorage]); + + interface StorageFiles { + files: { + filename: string; + size: number; + createdAt: string; + }[]; + } + + useEffect(() => { + syncStorage(); + }, [syncStorage]); + + function handleDeleteFile(file: { name: string; size: string; createdAt: string }) { + console.log("Deleting file:", file); + send("deleteStorageFile", { filename: file.name }, res => { + if ("error" in res) { + notifications.error(`Error deleting file: ${res.error}`); + return; + } + + syncStorage(); + }); + } + + function handleDownloadFile(file: { name: string }) { + const downloadUrl = `${DEVICE_API}/storage/download?file=${encodeURIComponent(file.name)}`; + const a = document.createElement("a"); + a.href = downloadUrl; + a.download = file.name; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + const indexOfLastFile = currentPage * filesPerPage; + const indexOfFirstFile = indexOfLastFile - filesPerPage; + const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile); + const totalPages = Math.ceil(onStorageFiles.length / filesPerPage); + + const handlePreviousPage = () => { + setCurrentPage(prev => Math.max(prev - 1, 1)); + }; + + const handleNextPage = () => { + setCurrentPage(prev => Math.min(prev + 1, totalPages)); + }; + + return ( +
    + +
    + + {onStorageFiles.length === 0 ? ( +
    +
    +
    + +

    + No images available +

    +

    + Upload an image to start virtual media mounting. +

    +
    +
    +
    +
    +
    + ) : ( +
    + {currentFiles.map((file, index) => ( + { + const selectedFile = onStorageFiles.find(f => f.name === file.name); + if (!selectedFile) return; + if ( + window.confirm( + "Are you sure you want to download " + selectedFile.name + "?", + ) + ) { + handleDownloadFile(selectedFile); + } + }} + onDelete={() => { + const selectedFile = onStorageFiles.find(f => f.name === file.name); + if (!selectedFile) return; + if ( + window.confirm( + "Are you sure you want to delete " + selectedFile.name + "?", + ) + ) { + handleDeleteFile(selectedFile); + } + }} + onContinueUpload={() => onNewImageClick(file.name)} + /> + ))} + + {onStorageFiles.length > filesPerPage && ( +
    +

    + Showing {indexOfFirstFile + 1} to{" "} + + {Math.min(indexOfLastFile, onStorageFiles.length)} + {" "} + of {onStorageFiles.length} results +

    +
    +
    +
    + )} +
    + )} +
    +
    + + {onStorageFiles.length > 0 ? ( +
    +
    +
    +
    + ) : ( +
    +
    +
    +
    + )} +
    +
    +
    + + Available Storage + + + {percentageUsed}% used + +
    +
    +
    +
    +
    + + {formatters.bytes(bytesUsed)} used + + + {formatters.bytes(bytesFree)} free + +
    +
    + + {onStorageFiles.length > 0 && ( +
    +
    + )} +
    + ); +} + +function SDFileView({ + onBack, + onNewImageClick, +}: { + onBack: () => void; + onNewImageClick: (incompleteFileName?: string) => void; +}) { + const [onStorageFiles, setOnStorageFiles] = useState< + { + name: string; + size: string; + createdAt: string; + }[] + >([]); + + const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(null); + const [selected, setSelected] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(false); + const filesPerPage = 5; + + const [send] = useJsonRpc(); + + interface StorageSpace { + bytesUsed: number; + bytesFree: number; + } + const [storageSpace, setStorageSpace] = useState(null); + + const percentageUsed = useMemo(() => { + if (!storageSpace) return 0; + return Number( + ( + (storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) * + 100 + ).toFixed(1), + ); + }, [storageSpace]); + + const bytesUsed = useMemo(() => { + if (!storageSpace) return 0; + return storageSpace.bytesUsed; + }, [storageSpace]); + + const bytesFree = useMemo(() => { + if (!storageSpace) return 0; + return storageSpace.bytesFree; + }, [storageSpace]); + + const syncStorage = useCallback(() => { + send("getSDMountStatus", {}, res => { + if ("error" in res) { + notifications.error(`Failed to check SD card status: ${res.error}`); + setSDMountStatus(null); + return; + } + + const { status } = res.result as { status: "ok" | "none" | "fail" }; + setSDMountStatus(status); + + if (status === "none") { + notifications.error("No SD card detected, please insert an SD card"); + return; + } + + if (status === "fail") { + notifications.error("SD card mount failed, please format the SD card"); + return; + } + + send("listSDStorageFiles", {}, res => { + if ("error" in res) { + notifications.error(`Error listing SD storage files: ${res.error}`); + return; + } + const { files } = res.result as StorageFiles; + const formattedFiles = files.map(file => ({ + name: file.filename, + size: formatters.bytes(file.size), + createdAt: formatters.date(new Date(file?.createdAt)), + })); + setOnStorageFiles(formattedFiles); + console.log("SD storage files:", formattedFiles); + }); + + send("getSDStorageSpace", {}, res => { + if ("error" in res) { + notifications.error(`Error getting SD storage space: ${res.error}`); + return; + } + const space = res.result as StorageSpace; + setStorageSpace(space); + }); + }); + }, [send]); + + useEffect(() => { + syncStorage(); + }, [syncStorage]); + + interface StorageFiles { + files: { + filename: string; + size: number; + createdAt: string; + }[]; + } + + useEffect(() => { + syncStorage(); + }, [syncStorage]); + + function handleSDDeleteFile(file: { name: string; size: string; createdAt: string }) { + console.log("Deleting file:", file); + send("deleteSDStorageFile", { filename: file.name }, res => { + if ("error" in res) { + notifications.error(`Error deleting file: ${res.error}`); + return; + } + + syncStorage(); + }); + } + + function handleSDDownloadFile(file: { name: string }) { + const downloadUrl = `${DEVICE_API}/storage/sd-download?file=${encodeURIComponent(file.name)}`; + const a = document.createElement("a"); + a.href = downloadUrl; + a.download = file.name; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + const indexOfLastFile = currentPage * filesPerPage; + const indexOfFirstFile = indexOfLastFile - filesPerPage; + const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile); + const totalPages = Math.ceil(onStorageFiles.length / filesPerPage); + + const handlePreviousPage = () => { + setCurrentPage(prev => Math.max(prev - 1, 1)); + }; + + const handleNextPage = () => { + setCurrentPage(prev => Math.min(prev + 1, totalPages)); + }; + + async function handleResetSDStorage() { + setLoading(true); + send("resetSDStorage", {}, res => { + console.log("Reset SD storage response:", res); + if ("error" in res) { + notifications.error(`Failed to reset SD card`); + setLoading(false); + return; + } + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); + syncStorage(); + } + + async function handleUnmountSDStorage() { + setLoading(true); + send("unmountSDStorage", {}, res => { + console.log("Unmount SD response:", res); + if ("error" in res) { + notifications.error(`Failed to unmount SD card`); + setLoading(false); + return; + } + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); + syncStorage(); + } + + async function handleMountSDStorage() { + setLoading(true); + send("mountSDStorage", {}, res => { + console.log("Mount SD response:", res); + if ("error" in res) { + notifications.error(`Failed to mount SD card`); + setLoading(false); + return; + } + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); + syncStorage(); + } + + if (sdMountStatus && sdMountStatus !== "ok") { + return ( +
    + +
    +
    + +

    + {sdMountStatus === "none" + ? "No SD card detected" + : "SD card mount failed"} +

    +

    + {sdMountStatus === "none" + ? "Please insert an SD card and try again." + : "Please format the SD card and try again."} +

    +
    +
    +
    + ); + } + + return ( +
    + +
    + + {onStorageFiles.length === 0 ? ( +
    +
    +
    + +

    + No images available +

    +

    + Upload a file. +

    +
    +
    +
    +
    +
    + ) : ( +
    + {currentFiles.map((file, index) => ( + { + const selectedFile = onStorageFiles.find(f => f.name === file.name); + if (!selectedFile) return; + if ( + window.confirm( + "Are you sure you want to download " + selectedFile.name + "?", + ) + ) { + handleSDDownloadFile(selectedFile); + } + }} + onDelete={() => { + const selectedFile = onStorageFiles.find(f => f.name === file.name); + if (!selectedFile) return; + if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) { + handleSDDeleteFile(selectedFile); + } + }} + onContinueUpload={() => onNewImageClick(file.name)} + /> + ))} + + {onStorageFiles.length > filesPerPage && ( +
    +

    + Showing {indexOfFirstFile + 1} to{" "} + + {Math.min(indexOfLastFile, onStorageFiles.length)} + {" "} + of {onStorageFiles.length} results +

    +
    +
    +
    + )} +
    + )} +
    +
    + + {onStorageFiles.length > 0 ? ( +
    +
    +
    +
    + ) : ( +
    +
    +
    +
    + )} +
    +
    +
    + + Available Storage + + + {percentageUsed}% used + +
    +
    +
    +
    +
    + + {formatters.bytes(bytesUsed)} used + + + {formatters.bytes(bytesFree)} free + +
    +
    + + {onStorageFiles.length > 0 && ( +
    +
    + )} + +
    +
    + +
    + ); +} + +function UploadFileView({ + onBack, + onCancelUpload, + incompleteFileName, + media, +}: { + onBack: () => void; + onCancelUpload: () => void; + incompleteFileName?: string; + media?: string; +}) { + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( + "idle", + ); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [uploadedFileSize, setUploadedFileSize] = useState(null); + const [uploadSpeed, setUploadSpeed] = useState(null); + const [fileError, setFileError] = useState(null); + const [uploadError, setUploadError] = useState(null); + + const [send] = useJsonRpc(); + const rtcDataChannelRef = useRef(null); + + useEffect(() => { + const ref = rtcDataChannelRef.current; + return () => { + console.log("unmounting"); + if (ref) { + ref.onopen = null; + ref.onerror = null; + ref.onmessage = null; + ref.onclose = null; + ref.close(); + } + }; + }, []); + + function handleWebRTCUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const rtcDataChannel = useRTCStore + .getState() + .peerConnection?.createDataChannel(dataChannel); + + if (!rtcDataChannel) { + console.error("Failed to create data channel for file upload"); + notifications.error("Failed to create data channel for file upload"); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + + return; + } + + rtcDataChannelRef.current = rtcDataChannel; + + const lowWaterMark = 256 * 1024; + const highWaterMark = 1 * 1024 * 1024; + rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + rtcDataChannel.onmessage = e => { + try { + const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { + AlreadyUploadedBytes: number; + Size: number; + }; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((AlreadyUploadedBytes / Size) * 100); + } + + lastUploadedBytes = AlreadyUploadedBytes; + lastUpdateTime = now; + } catch (e) { + console.error("Error processing RTC Data channel message:", e); + } + }; + + rtcDataChannel.onopen = () => { + let pauseSending = false; // Pause sending when the buffered amount is high + const chunkSize = 4 * 1024; // 4KB chunks + + let offset = alreadyUploadedBytes; + const sendNextChunk = () => { + if (offset >= file.size) { + rtcDataChannel.close(); + setUploadState("success"); + return; + } + + if (pauseSending) return; + + const chunk = file.slice(offset, offset + chunkSize); + chunk.arrayBuffer().then(buffer => { + rtcDataChannel.send(buffer); + + if (rtcDataChannel.bufferedAmount >= highWaterMark) { + pauseSending = true; + } + + offset += buffer.byteLength; + console.log(`Chunk sent: ${offset} / ${file.size} bytes`); + sendNextChunk(); + }); + }; + + sendNextChunk(); + rtcDataChannel.onbufferedamountlow = () => { + console.log("RTC Data channel buffered amount low"); + pauseSending = false; // Now the data channel is ready to send more data + sendNextChunk(); + }; + }; + + rtcDataChannel.onerror = error => { + console.error("RTC Data channel error:", error); + notifications.error(`Upload failed: ${error}`); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + }; + } + + async function handleHttpUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`; + + const xhr = new XMLHttpRequest(); + xhr.open("POST", uploadUrl, true); + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + const totalUploaded = alreadyUploadedBytes + event.loaded; + const totalSize = file.size; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = totalUploaded - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((totalUploaded / totalSize) * 100); + } + + lastUploadedBytes = totalUploaded; + lastUpdateTime = now; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + setUploadState("success"); + } else { + console.error("Upload error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + } + }; + + xhr.onerror = () => { + console.error("XHR error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + }; + + // Prepare the data to send + const blob = file.slice(alreadyUploadedBytes); + + // Send the file data + xhr.send(blob); + } + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Reset the upload error when a new file is selected + setUploadError(null); + + if ( + incompleteFileName && + file.name !== incompleteFileName.replace(".incomplete", "") + ) { + setFileError( + `Please select the file "${incompleteFileName.replace(".incomplete", "")}" to continue the upload.`, + ); + return; + } + + setFileError(null); + console.log(`File selected: ${file.name}, size: ${file.size} bytes`); + setUploadedFileName(file.name); + setUploadedFileSize(file.size); + setUploadState("uploading"); + console.log("Upload state set to 'uploading'"); + + if ( media === "sd" ) { + send("startSDStorageFileUpload", { filename: file.name, size: file.size }, resp => { + console.log("startSDStorageFileUpload response:", resp); + if ("error" in resp) { + console.error("Upload error:", resp.error.message); + setUploadError(resp.error.data || resp.error.message); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + return; + } + + const { alreadyUploadedBytes, dataChannel } = resp.result as { + alreadyUploadedBytes: number; + dataChannel: string; + }; + + console.log( + `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, + ); + + if (isOnDevice) { + handleHttpUpload(file, alreadyUploadedBytes, dataChannel); + } else { + handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); + } + }); + } + else { + send("startStorageFileUpload", { filename: file.name, size: file.size }, resp => { + console.log("startStorageFileUpload response:", resp); + if ("error" in resp) { + console.error("Upload error:", resp.error.message); + setUploadError(resp.error.data || resp.error.message); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + return; + } + + const { alreadyUploadedBytes, dataChannel } = resp.result as { + alreadyUploadedBytes: number; + dataChannel: string; + }; + + console.log( + `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, + ); + + if (isOnDevice) { + handleHttpUpload(file, alreadyUploadedBytes, dataChannel); + } else { + handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); + } + }); + } + } + }; + + return ( +
    + +
    +
    { + if (uploadState === "idle") { + document.getElementById("file-upload")?.click(); + } + }} + className="block select-none" + > +
    + +
    +
    + {uploadState === "idle" && ( +
    +
    + +
    + +
    +
    +
    +

    + {incompleteFileName + ? `Click to select "${incompleteFileName.replace(".incomplete", "")}"` + : "Click to select a file"} +

    +

    + Do not support directory +

    +
    + )} + + {uploadState === "uploading" && ( +
    +
    + +
    + +
    +
    +
    +

    + Uploading {formatters.truncateMiddle(uploadedFileName, 30)} +

    +

    + {formatters.bytes(uploadedFileSize || 0)} +

    +
    +
    +
    +
    +
    + Uploading... + + {uploadSpeed !== null + ? `${formatters.bytes(uploadSpeed)}/s` + : "Calculating..."} + +
    +
    +
    + )} + + {uploadState === "success" && ( +
    +
    + +
    + +
    +
    +
    +

    + Upload successful +

    +

    + {formatters.truncateMiddle(uploadedFileName, 40)} has been + uploaded +

    +
    + )} +
    +
    +
    +
    +
    + + {fileError && ( +

    {fileError}

    + )} +
    + + {/* Display upload error if present */} + {uploadError && ( +
    + Error: {uploadError} +
    + )} + +
    +
    + {uploadState === "uploading" ? ( +
    +
    +
    +
    + ); +} + +function ErrorView({ + errorMessage, + onClose, + onRetry, +}: { + errorMessage: string | null; + onClose: () => void; + onRetry: () => void; +}) { + return ( +
    +
    +
    + +

    Mount Error

    +
    +

    + An error occurred while attempting to mount the media. Please try again. +

    +
    + {errorMessage && ( + +

    {errorMessage}

    +
    + )} +
    +
    +
    + ); +} + +function PreUploadedImageItem({ + name, + size, + uploadedAt, + isSelected, + isIncomplete, + onDownload, + onDelete, + onContinueUpload, +}: { + name: string; + size: string; + uploadedAt: string; + isSelected: boolean; + isIncomplete: boolean; + onDownload: () => void; + onDelete: () => void; + onContinueUpload: () => void; +}) { + const [isHovering, setIsHovering] = useState(false); + return ( + + ); +} + +function ViewHeader({ title, description }: { title: string; description: string }) { + return ( +
    +

    + {title} +

    +
    + {description} +
    +
    + ); +} diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index b3602e2..d41df1f 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -22,6 +22,8 @@ import { CloudState } from "./adopt"; import { useVpnStore } from "@/hooks/stores"; import Checkbox from "../components/Checkbox"; +import { LogDialog } from "../components/LogDialog"; + export interface TailScaleResponse { state: string; loginUrl: string; @@ -35,6 +37,11 @@ export interface ZeroTierResponse { ip: string; } +export interface FrpcResponse { + running: boolean; +} + + export interface TLSState { mode: "self-signed" | "custom" | "disabled"; certificate?: string; @@ -83,6 +90,11 @@ export default function SettingsAccessIndexRoute() { const [tempNetworkID, setTempNetworkID] = useState(""); const [isDisconnecting, setIsDisconnecting] = useState(false); + + const [frpcToml, setFrpcToml] = useState(""); + const [frpcLog, setFrpcLog] = useState(""); + const [showFrpcLogModal, setShowFrpcLogModal] = useState(false); + const [frpcStatus, setFrpcRunningStatus] = useState({ running: false }); const getTLSState = useCallback(() => { send("getTLSState", {}, resp => { @@ -262,6 +274,77 @@ export default function SettingsAccessIndexRoute() { }); },[send, zeroTierNetworkID]); + const handleStartFrpc = useCallback(() => { + send("startFrpc", { frpcToml }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to start frpc: ${resp.error.data || "Unknown error"}`, + ); + setFrpcRunningStatus({ running: false }); + return; + } + notifications.success("frpc started"); + setFrpcRunningStatus({ running: true }); + }); + }, [send, frpcToml]); + + const handleStopFrpc = useCallback(() => { + send("stopFrpc", { frpcToml }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to stop frpc: ${resp.error.data || "Unknown error"}`, + ); + return; + } + notifications.success("frpc stopped"); + setFrpcRunningStatus({ running: false }); + }); + }, [send]); + + const handleGetFrpcLog = useCallback(() => { + send("getFrpcLog", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to get frpc log: ${resp.error.data || "Unknown error"}`, + ); + setFrpcLog(""); + return; + } + setFrpcLog(resp.result as string); + setShowFrpcLogModal(true); + }); + }, [send]); + + const getFrpcToml = useCallback(() => { + send("getFrpcToml", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to get frpc toml: ${resp.error.data || "Unknown error"}`, + ); + setFrpcToml(""); + return; + } + setFrpcToml(resp.result as string); + }); + }, [send]); + + const getFrpcStatus = useCallback(() => { + send("getFrpcStatus", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to get frpc status: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setFrpcRunningStatus(resp.result as FrpcResponse); + }); + }, [send]); + + useEffect(() => { + getFrpcStatus(); + getFrpcToml(); + }, [getFrpcStatus, getFrpcToml]); + return (
    -
    - { ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && ( -
    + +
    + { ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && ( +
    +
    @@ -558,7 +646,58 @@ export default function SettingsAccessIndexRoute() { )}
    +
    + +
    + setFrpcToml(e.target.value)} + /> +
    + {frpcStatus.running ? ( +
    +
    + ) : ( +
    +
    + + + + { + setShowFrpcLogModal(false); + }} + title="Frpc Log" + description={frpcLog} + /> + ); } diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 8baa035..2058164 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -5,13 +5,11 @@ import { SettingsItem } from "@routes/devices.$id.settings"; import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; -import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { InputField } from "@/components/InputField"; import { Button, LinkButton } from "@/components/Button"; import notifications from "../notifications"; -import { UsbInfoSetting } from "../components/UsbInfoSetting"; -import { FeatureFlag } from "../components/FeatureFlag"; +import { UsbEpModeSetting } from "@components/UsbEpModeSetting"; export default function SettingsHardwareRoute() { const [send] = useJsonRpc(); @@ -333,13 +331,9 @@ export default function SettingsHardwareRoute() { - - - - - - - + + {/* */} + {/* */} ); } diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index d00cb14..547c985 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -40,16 +40,9 @@ const streamQualityOptions = [ { value: "0.1", label: "Low" }, ]; -const audioModeOptions = [ - { value: "disabled", label: "Disabled"}, - { value: "usb", label: "USB"}, - //{ value: "hdmi", label: "HDMI"}, -] - export default function SettingsVideoRoute() { const [send] = useJsonRpc(); const [streamQuality, setStreamQuality] = useState("1"); - const [audioMode, setAudioMode] = useState("disabled"); const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); @@ -62,11 +55,6 @@ export default function SettingsVideoRoute() { const setVideoContrast = useSettingsStore(state => state.setVideoContrast); useEffect(() => { - send("getAudioMode", {}, resp => { - if ("error" in resp) return; - setAudioMode(String(resp.result)); - }); - send("getStreamQualityFactor", {}, resp => { if ("error" in resp) return; setStreamQuality(String(resp.result)); @@ -95,21 +83,7 @@ export default function SettingsVideoRoute() { } }); }, [send]); - - const handleAudioModeChange = (mode: string) => { - send("setAudioMode", { mode }, resp => { - if ("error" in resp) { - notifications.error( - `Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`, - ); - return; - } - - notifications.success(`Audio Mode set to ${audioModeOptions.find(x => x.value === mode )?.label}.It takes effect after refreshing the page`); - setAudioMode(mode); - }); - }; - + const handleStreamQualityChange = (factor: string) => { send("setStreamQualityFactor", { factor: Number(factor) }, resp => { if ("error" in resp) { @@ -149,20 +123,6 @@ export default function SettingsVideoRoute() {
    - - handleAudioModeChange(e.target.value)} - /> - - 0 { + cmd := exec.Command("killall", "umtprd") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to killall umtprd: %w", err) + } + } + + cmd := exec.Command("umtprd") + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to exec binary: %w", err) + } + + if err := rpcSetUsbDevices(*config.UsbDevices); err != nil { + return fmt.Errorf("failed to set usb devices: %w", err) + } + } + + return nil +} diff --git a/vpn.go b/vpn.go index 4c4dfda..a0155f5 100644 --- a/vpn.go +++ b/vpn.go @@ -1,8 +1,12 @@ package kvm import ( + "bufio" "encoding/json" "fmt" + "os" + "os/exec" + "path/filepath" "syscall" "time" ) @@ -30,8 +34,6 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) { IP: "", } - //fmt.Printf("[rpcLoginTailScale] xEdge: %v\n", xEdge) - _, err := CallVpnCtrlAction("login_tailscale", map[string]interface{}{"xEdge": xEdge}) if err != nil { return settings, err @@ -63,13 +65,19 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) { case "logined": config.TailScaleAutoStart = true config.TailScaleXEdge = settings.XEdge - SaveConfig() - return settings, nil + err := SaveConfig() + if err != nil { + vpnLogger.Error().Err(err).Msg("failed to save config") + } + return settings, err case "connected": config.TailScaleAutoStart = true config.TailScaleXEdge = settings.XEdge - SaveConfig() - return settings, nil + err = SaveConfig() + if err != nil { + vpnLogger.Error().Err(err).Msg("failed to save config") + } + return settings, err case "connecting": if i >= 10 { settings.State = "disconnected" @@ -77,7 +85,10 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) { settings.State = "connecting" } case "cancel": - go rpcLogoutTailScale() + err := rpcLogoutTailScale() + if err != nil { + vpnLogger.Error().Err(err).Msg("failed to logout tailscale") + } settings.State = "disconnected" return settings, nil default: @@ -135,7 +146,6 @@ type ZeroTierSettings struct { func rpcLoginZeroTier(networkID string) (ZeroTierSettings, error) { LoadConfig() - //fmt.Printf("[rpcLoginZeroTier] networkID: %s\n", networkID) settings := ZeroTierSettings{ State: "connecting", NetworkID: networkID, @@ -161,13 +171,14 @@ func rpcLoginZeroTier(networkID string) (ZeroTierSettings, error) { } } - if settings.State == "closed" { + switch settings.State { + case "closed": config.ZeroTierAutoStart = false config.ZeroTierNetworkID = "" if err := SaveConfig(); err != nil { vpnLogger.Error().Err(err).Msg("failed to save config") } - } else if settings.State == "connected" || settings.State == "logined" { + case "connected", "logined": config.ZeroTierAutoStart = true config.ZeroTierNetworkID = settings.NetworkID if err := SaveConfig(); err != nil { @@ -243,27 +254,146 @@ func HandleVpnDisplayUpdateMessage(event CtrlResponse) { return } - if vpnUpdateDisplayState.TailScaleState == "connected" { + switch vpnUpdateDisplayState.TailScaleState { + case "connected": updateLabelIfChanged("Network_TailScale_Label", "Connected") - } else if vpnUpdateDisplayState.TailScaleState == "logined" { + case "logined": updateLabelIfChanged("Network_TailScale_Label", "Logined") - } else { + default: updateLabelIfChanged("Network_TailScale_Label", "Disconnected") } - if vpnUpdateDisplayState.ZeroTierState == "connected" { + switch vpnUpdateDisplayState.ZeroTierState { + case "connected": updateLabelIfChanged("Network_ZeroTier_Label", "Connected") - } else if vpnUpdateDisplayState.ZeroTierState == "logined" { + case "logined": updateLabelIfChanged("Network_ZeroTier_Label", "Logined") - } else { + default: updateLabelIfChanged("Network_ZeroTier_Label", "Disconnected") } +} +type FrpcStatus struct { + Running bool `json:"running"` +} + +var ( + frpcTomlPath = "/userdata/frpc/frpc.toml" + frpcLogPath = "/tmp/frpc.log" +) + +func frpcRunning() bool { + cmd := exec.Command("pgrep", "-x", "frpc") + return cmd.Run() == nil +} + +func rpcGetFrpcLog() (string, error) { + f, err := os.Open(frpcLogPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("frpc log file not exist") + } + return "", err + } + defer f.Close() + + const want = 30 + lines := make([]string, 0, want+10) + sc := bufio.NewScanner(f) + for sc.Scan() { + lines = append(lines, sc.Text()) + if len(lines) > want { + lines = lines[1:] + } + } + if err := sc.Err(); err != nil { + return "", err + } + + var buf []byte + for _, l := range lines { + buf = append(buf, l...) + buf = append(buf, '\n') + } + return string(buf), nil +} + +func rpcGetFrpcToml() (string, error) { + return config.FrpcToml, nil +} + +func rpcStartFrpc(frpcToml string) error { + if frpcRunning() { + _ = exec.Command("pkill", "-x", "frpc").Run() + } + + if frpcToml != "" { + _ = os.MkdirAll(filepath.Dir(frpcTomlPath), 0700) + if err := os.WriteFile(frpcTomlPath, []byte(frpcToml), 0600); err != nil { + return err + } + cmd := exec.Command("frpc", "-c", frpcTomlPath) + cmd.Stdout = nil + cmd.Stderr = nil + logFile, err := os.OpenFile(frpcLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer logFile.Close() + cmd.Stdout = logFile + cmd.Stderr = logFile + + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start frpc failed: %w", err) + } else { + config.FrpcAutoStart = true + config.FrpcToml = frpcToml + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } + } else { + return fmt.Errorf("frpcToml is empty") + } + + return nil +} + +func rpcStopFrpc() error { + if frpcRunning() { + err := exec.Command("pkill", "-x", "frpc").Run() + if err != nil { + return fmt.Errorf("failed to stop frpc: %w", err) + } + } + + config.FrpcAutoStart = false + err := SaveConfig() + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func rpcGetFrpcStatus() (FrpcStatus, error) { + return FrpcStatus{Running: frpcRunning()}, nil } func initVPN() { waitVpnCtrlClientConnected() go func() { + for { + if !networkState.IsOnline() { + vpnLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds") + time.Sleep(3 * time.Second) + continue + } else { + break + } + } + if config.TailScaleAutoStart { if _, err := rpcLoginTailScale(config.TailScaleXEdge); err != nil { vpnLogger.Error().Err(err).Msg("Failed to auto start TailScale") @@ -275,6 +405,12 @@ func initVPN() { vpnLogger.Error().Err(err).Msg("Failed to auto start ZeroTier") } } + + if config.FrpcAutoStart && config.FrpcToml != "" { + if err := rpcStartFrpc(config.FrpcToml); err != nil { + vpnLogger.Error().Err(err).Msg("Failed to auto start frpc") + } + } }() go func() { diff --git a/web.go b/web.go index 4c99bfd..9d2de4f 100644 --- a/web.go +++ b/web.go @@ -158,6 +158,8 @@ func setupRouter() *gin.Engine { protected.PUT("/auth/password-local", handleUpdatePassword) protected.DELETE("/auth/local-password", handleDeletePassword) protected.POST("/storage/upload", handleUploadHttp) + protected.GET("/storage/download", handleDownloadHttp) + protected.GET("/storage/sd-download", handleSDDownloadHttp) } // Catch-all route for SPA @@ -214,12 +216,17 @@ var ( func handleLocalWebRTCSignal(c *gin.Context) { // get the source from the request source := c.ClientIP() - connectionID := uuid.New().String() + + connectionID := c.Query("id") + if connectionID == "" { + connectionID = uuid.New().String() + } scopedLogger := websocketLogger.With(). Str("component", "websocket"). Str("source", source). Str("sourceType", "local"). + Str("connectionID", connectionID). Logger() scopedLogger.Info().Msg("new websocket connection established") @@ -246,7 +253,10 @@ func handleLocalWebRTCSignal(c *gin.Context) { // Now use conn for websocket operations defer wsCon.Close(websocket.StatusNormalClosure, "") - err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}}) + err = wsjson.Write(context.Background(), wsCon, gin.H{ + "type": "device-metadata", + "data": gin.H{"deviceVersion": builtAppVersion, "connectionID": connectionID}, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -271,8 +281,7 @@ func handleWebRTCSignalWsMessages( }() // connection type - var sourceType string - sourceType = "local" + var sourceType = "local" l := scopedLogger.With(). Str("source", source). diff --git a/webrtc.go b/webrtc.go index 43aa18a..966d8e1 100644 --- a/webrtc.go +++ b/webrtc.go @@ -68,23 +68,38 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) { return base64.StdEncoding.EncodeToString(localDescription), nil } -func newSession(config SessionConfig) (*Session, error) { +func newSession(sessionConfig SessionConfig) (*Session, error) { webrtcSettingEngine := webrtc.SettingEngine{ LoggerFactory: logging.GetPionDefaultLoggerFactory(), } - iceServer := webrtc.ICEServer{} + webrtcSettingEngine.SetNetworkTypes([]webrtc.NetworkType{ + webrtc.NetworkTypeUDP4, + webrtc.NetworkTypeUDP6, + }) + //iceServer := webrtc.ICEServer{} var scopedLogger *zerolog.Logger - if config.Logger != nil { - l := config.Logger.With().Str("component", "webrtc").Logger() + if sessionConfig.Logger != nil { + l := sessionConfig.Logger.With().Str("component", "webrtc").Logger() scopedLogger = &l } else { scopedLogger = webrtcLogger } + iceServers := []webrtc.ICEServer{ + { + URLs: []string{"stun:stun.l.google.com:19302"}, + }, + } + if config.STUN != "" { + iceServers = append(iceServers, webrtc.ICEServer{ + URLs: []string{config.STUN}, + }) + } + api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine)) peerConnection, err := api.NewPeerConnection(webrtc.Configuration{ - ICEServers: []webrtc.ICEServer{iceServer}, + ICEServers: iceServers, }) if err != nil { return nil, err @@ -162,7 +177,7 @@ func newSession(config SessionConfig) (*Session, error) { peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate") if candidate != nil { - err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()}) + err := wsjson.Write(context.Background(), sessionConfig.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()}) if err != nil { scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel") }