Update App version to 0.0.2

This commit is contained in:
luckfox-eng29
2025-09-16 11:03:46 +08:00
parent 8fbd6bcf0d
commit 15d276652c
45 changed files with 3347 additions and 252 deletions

4
.gitignore vendored
View File

@@ -3,5 +3,9 @@ static/*
.idea .idea
.DS_Store .DS_Store
.vscode .vscode
package-lock.json
package.json
node_modules/
device-tests.tar.gz device-tests.tar.gz

View File

@@ -5,6 +5,8 @@ linters:
- misspell - misspell
- whitespace - whitespace
- gochecknoinits - gochecknoinits
disable:
- unused
settings: settings:
forbidigo: forbidigo:
forbid: forbid:

View File

@@ -1,3 +0,0 @@
{
"tailwindCSS.classFunctions": ["cva", "cx"]
}

View File

@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s) BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD) REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV ?= 0.0.1-dev VERSION_DEV ?= 0.0.2-dev
VERSION ?= 0.0.1 VERSION ?= 0.0.2
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm KVM_PKG_NAME := kvm

View File

@@ -99,7 +99,10 @@ func StartNtpAudioServer(handleClient func(net.Conn)) {
} }
func StopNtpAudioServer() { 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 { if audioListener != nil {
audioListener.Close() audioListener.Close()
@@ -138,7 +141,7 @@ func handleAudioClient(conn net.Conn) {
} }
timestamp += timestampStep timestamp += timestampStep
packet.Header.Timestamp = timestamp packet.Timestamp = timestamp
buf, err := packet.Marshal() buf, err := packet.Marshal()
if err != nil { if err != nil {
audioLogger.Warn().Err(err).Msg("error marshalling packet") audioLogger.Warn().Err(err).Msg("error marshalling packet")

View File

@@ -118,8 +118,7 @@ func handleSessionRequest(
source string, source string,
scopedLogger *zerolog.Logger, scopedLogger *zerolog.Logger,
) error { ) error {
var sourceType string var sourceType = "local"
sourceType = "local"
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v) metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)

190
config.go
View File

@@ -1,9 +1,14 @@
package kvm package kvm
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"sync" "sync"
"kvm/internal/logging" "kvm/internal/logging"
@@ -75,8 +80,7 @@ func (m *KeyboardMacro) Validate() error {
} }
type Config struct { type Config struct {
CloudToken string `json:"cloud_token"` STUN string `json:"stun"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"` JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"` IncludePreRelease bool `json:"include_pre_release"`
@@ -102,6 +106,8 @@ type Config struct {
TailScaleXEdge bool `json:"tailscale_xedge"` TailScaleXEdge bool `json:"tailscale_xedge"`
ZeroTierNetworkID string `json:"zerotier_network_id"` ZeroTierNetworkID string `json:"zerotier_network_id"`
ZeroTierAutoStart bool `json:"zerotier_autostart"` ZeroTierAutoStart bool `json:"zerotier_autostart"`
FrpcAutoStart bool `json:"frpc_autostart"`
FrpcToml string `json:"frpc_toml"`
IO0Status bool `json:"io0_status"` IO0Status bool `json:"io0_status"`
IO1Status bool `json:"io1_status"` IO1Status bool `json:"io1_status"`
AudioMode string `json:"audio_mode"` AudioMode string `json:"audio_mode"`
@@ -111,8 +117,10 @@ type Config struct {
} }
const configPath = "/userdata/kvm_config.json" const configPath = "/userdata/kvm_config.json"
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
var defaultConfig = &Config{ var defaultConfig = &Config{
STUN: "stun:stun.l.google.com:19302",
AutoUpdateEnabled: false, // Set a default value AutoUpdateEnabled: false, // Set a default value
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
@@ -135,13 +143,15 @@ var defaultConfig = &Config{
RelativeMouse: true, RelativeMouse: true,
Keyboard: true, Keyboard: true,
MassStorage: 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{}, NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO", DefaultLogLevel: "INFO",
ZeroTierAutoStart: false, ZeroTierAutoStart: false,
TailScaleAutoStart: false, TailScaleAutoStart: false,
TailScaleXEdge: false, TailScaleXEdge: false,
FrpcAutoStart: false,
IO0Status: true, IO0Status: true,
IO1Status: true, IO1Status: true,
AudioMode: "disabled", AudioMode: "disabled",
@@ -165,6 +175,14 @@ func LoadConfig() {
// load the default config // load the default config
config = defaultConfig 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) file, err := os.Open(configPath)
if err != nil { if err != nil {
@@ -177,6 +195,10 @@ func LoadConfig() {
loadedConfig := *defaultConfig loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed") logger.Warn().Err(err).Msg("config file JSON parsing failed")
os.Remove(configPath)
if _, err := os.Stat(sdConfigPath); err == nil {
os.Remove(sdConfigPath)
}
return return
} }
@@ -200,6 +222,74 @@ func LoadConfig() {
logger.Info().Str("path", configPath).Msg("config loaded") 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 { func SaveConfig() error {
configLock.Lock() configLock.Lock()
defer configLock.Unlock() defer configLock.Unlock()
@@ -218,6 +308,8 @@ func SaveConfig() error {
return fmt.Errorf("failed to encode config: %w", err) return fmt.Errorf("failed to encode config: %w", err)
} }
SyncConfigSD(false)
return nil return nil
} }
@@ -226,3 +318,95 @@ func ensureConfigLoaded() {
LoadConfig() 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
}

View File

@@ -109,10 +109,14 @@ func updateDisplay() {
} else { } else {
_, _ = lvObjSetState("Main", "USB_DISCONNECTED") _, _ = lvObjSetState("Main", "USB_DISCONNECTED")
} }
_ = os.WriteFile("/userdata/usb_state", []byte(usbState), 0644)
if lastVideoState.Ready { if lastVideoState.Ready {
_, _ = lvObjSetState("Main", "HDMI_CONNECTED") _, _ = lvObjSetState("Main", "HDMI_CONNECTED")
_ = os.WriteFile("/userdata/hdmi_state", []byte("connected"), 0644)
} else { } else {
_, _ = lvObjSetState("Main", "HDMI_DISCONNECTED") _, _ = lvObjSetState("Main", "HDMI_DISCONNECTED")
_ = os.WriteFile("/userdata/hdmi_state", []byte("disconnected"), 0644)
} }
if networkState.IsUp() { if networkState.IsUp() {
@@ -120,7 +124,6 @@ func updateDisplay() {
} else { } else {
_, _ = lvObjSetState("Network", "NO_NETWORK") _, _ = lvObjSetState("Network", "NO_NETWORK")
} }
} }
var ( var (

View File

@@ -36,8 +36,9 @@ type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"` Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` 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"` IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`

View File

@@ -95,6 +95,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
opts.OnDhcpLeaseChange(lease) opts.OnDhcpLeaseChange(lease)
}, },
RequestAddress: s.config.IPv4RequestAddress.String,
}) })
s.dhcpClient = dhcpClient s.dhcpClient = dhcpClient

View File

@@ -3,10 +3,13 @@ package udhcpc
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"sync"
"syscall"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -21,20 +24,22 @@ const (
) )
type DHCPClient struct { type DHCPClient struct {
InterfaceName string InterfaceName string
leaseFile string leaseFile string
pidFile string pidFile string
lease *Lease requestAddress string
logger *zerolog.Logger lease *Lease
process *os.Process logger *zerolog.Logger
onLeaseChange func(lease *Lease) process *os.Process
onLeaseChange func(lease *Lease)
} }
type DHCPClientOptions struct { type DHCPClientOptions struct {
InterfaceName string InterfaceName string
PidFile string PidFile string
Logger *zerolog.Logger Logger *zerolog.Logger
OnLeaseChange func(lease *Lease) OnLeaseChange func(lease *Lease)
RequestAddress string
} }
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) 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() l := options.Logger.With().Str("interface", options.InterfaceName).Logger()
return &DHCPClient{ return &DHCPClient{
InterfaceName: options.InterfaceName, InterfaceName: options.InterfaceName,
logger: &l, logger: &l,
leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName),
pidFile: options.PidFile, pidFile: options.PidFile,
onLeaseChange: options.OnLeaseChange, onLeaseChange: options.OnLeaseChange,
requestAddress: options.RequestAddress,
} }
} }
@@ -80,8 +86,8 @@ func (c *DHCPClient) watchLink() {
for update := range ch { for update := range ch {
if update.Link.Attrs().Name == c.InterfaceName { if update.Link.Attrs().Name == c.InterfaceName {
if update.IfInfomsg.Flags&unix.IFF_RUNNING != 0 { if update.Flags&unix.IFF_RUNNING != 0 {
fmt.Printf("[watchLink]link is up, starting udhcpc") c.logger.Info().Msg("link is up, starting udhcpc")
go c.runUDHCPC() go c.runUDHCPC()
} else { } else {
c.logger.Info().Msg("link is down") 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() { func (c *DHCPClient) runUDHCPC() {
cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1") cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1")
cmd.Stdout = os.Stdout if c.requestAddress != "" {
cmd.Stderr = os.Stderr 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 { if err := cmd.Run(); err != nil {
c.logger.Error().Err(err).Msg("failed to run udhcpc") c.logger.Error().Err(err).Msg("failed to run udhcpc")
} }
@@ -113,6 +154,7 @@ func (c *DHCPClient) Run() error {
} }
defer watcher.Close() defer watcher.Close()
go c.runUDHCPC()
go c.watchLink() go c.watchLink()
go func() { go func() {

View File

@@ -34,6 +34,7 @@ const (
FileStateFileWrite // update file content without checking FileStateFileWrite // update file content without checking
FileStateMounted FileStateMounted
FileStateMountedConfigFS FileStateMountedConfigFS
FileStateMountedFunctionFS
FileStateSymlink FileStateSymlink
FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order
FileStateSymlinkNotInOrderConfigFS FileStateSymlinkNotInOrderConfigFS
@@ -52,6 +53,7 @@ var FileStateString = map[FileState]string{
FileStateSymlink: "SYMLINK", FileStateSymlink: "SYMLINK",
FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS", FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS",
FileStateTouch: "TOUCH", FileStateTouch: "TOUCH",
FileStateMountedFunctionFS: "FUNCTIONFS_MOUNTED",
} }
const ( const (
@@ -78,6 +80,7 @@ const (
FileChangeResolvedActionRemoveDirectory FileChangeResolvedActionRemoveDirectory
FileChangeResolvedActionTouch FileChangeResolvedActionTouch
FileChangeResolvedActionMountConfigFS FileChangeResolvedActionMountConfigFS
FileChangeResolvedActionMountFunctionFS
) )
var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{ var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{
@@ -96,6 +99,7 @@ var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{
FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE", FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE",
FileChangeResolvedActionTouch: "TOUCH", FileChangeResolvedActionTouch: "TOUCH",
FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT", FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT",
FileChangeResolvedActionMountFunctionFS: "FUNCTIONFS_MOUNT",
} }
type ChangeSet struct { type ChangeSet struct {
@@ -147,6 +151,8 @@ func (f *RequestedFileChange) String() string {
s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent) s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateMountedConfigFS: case FileStateMountedConfigFS:
s = fmt.Sprintf("configfs: %s", f.Path) s = fmt.Sprintf("configfs: %s", f.Path)
case FileStateMountedFunctionFS:
s = fmt.Sprintf("functionfs: %s", f.Path)
case FileStateTouch: case FileStateTouch:
s = fmt.Sprintf("touch: %s", f.Path) s = fmt.Sprintf("touch: %s", f.Path)
case FileStateUnknown: case FileStateUnknown:
@@ -298,6 +304,8 @@ func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction {
return FileChangeResolvedActionWriteFile return FileChangeResolvedActionWriteFile
case FileStateTouch: case FileStateTouch:
return FileChangeResolvedActionTouch return FileChangeResolvedActionTouch
case FileStateMountedFunctionFS:
return FileChangeResolvedActionMountFunctionFS
} }
// get the actual state of the file // 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()) return os.Chtimes(change.Path, time.Now(), time.Now())
case FileChangeResolvedActionMountConfigFS: case FileChangeResolvedActionMountConfigFS:
return mountConfigFS(change.Path) return mountConfigFS(change.Path)
case FileChangeResolvedActionMountFunctionFS:
return mountFunctionFS(change.Path)
case FileChangeResolvedActionDoNothing: case FileChangeResolvedActionDoNothing:
return nil return nil
default: default:

View File

@@ -2,6 +2,7 @@ package usbgadget
import ( import (
"fmt" "fmt"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag" "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") l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change) err := c.changeset.applyChange(change)
if err != nil {
time.Sleep(500 * time.Millisecond)
err = c.changeset.applyChange(change)
}
if err != nil { if err != nil {
if change.IgnoreErrors { if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error") c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")

View File

@@ -1,8 +1,11 @@
package usbgadget package usbgadget
import ( import (
"bufio"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings"
) )
type gadgetConfigItem struct { type gadgetConfigItem struct {
@@ -51,6 +54,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
"configuration": "Config 1: HID", "configuration": "Config 1: HID",
}, },
}, },
// mtp
"mtp": mtpConfig,
// keyboard HID // keyboard HID
"keyboard": keyboardConfig, "keyboard": keyboardConfig,
// mouse HID // mouse HID
@@ -91,6 +96,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage return u.enabledDevices.MassStorage
case "mass_storage_lun0": case "mass_storage_lun0":
return u.enabledDevices.MassStorage return u.enabledDevices.MassStorage
case "mtp":
return u.enabledDevices.Mtp
case "audio": case "audio":
return u.enabledDevices.Audio return u.enabledDevices.Audio
default: default:
@@ -184,6 +191,45 @@ func mountConfigFS(path string) error {
return nil 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 { func (u *UsbGadget) Init() error {
u.configLock.Lock() u.configLock.Lock()
defer u.configLock.Unlock() defer u.configLock.Unlock()

View File

@@ -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() { func (tx *UsbGadgetTransaction) CreateConfigPath() {
tx.mkdirAll( tx.mkdirAll(
"gadget", "gadget",
@@ -164,7 +174,12 @@ func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
deps = tx.writeGadgetItemConfig(item, deps) 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 { 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) // bound the gadget to a UDC (USB Device Controller)
path := path.Join(tx.kvmGadgetPath, "UDC") path := path.Join(tx.kvmGadgetPath, "UDC")
tx.addFileChange("udc", RequestedFileChange{ if mtpServer {
Key: "udc", tx.addFileChange("udc", RequestedFileChange{
Path: path, Key: "udc",
ExpectedState: FileStateFileContentMatch, Path: path,
ExpectedContent: []byte(tx.udc), ExpectedState: FileStateFileContentMatch,
DependsOn: []string{"reorder-symlinks"}, ExpectedContent: []byte(tx.udc),
Description: "write 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) { func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {

View File

@@ -1,3 +1,4 @@
package usbgadget package usbgadget
const dwc3Path = "/sys/bus/platform/drivers/dwc3" const dwc3Path = "/sys/bus/platform/drivers/dwc3"
const udcPath = "/sys/kernel/config/usb_gadget/kvm/UDC"

View File

@@ -23,3 +23,10 @@ var massStorageLun0Config = gadgetConfigItem{
"inquiry_string": "KVM Virtual Media", "inquiry_string": "KVM Virtual Media",
}, },
} }
var mtpConfig = gadgetConfigItem{
order: 3003,
device: "ffs.mtp",
path: []string{"functions", "ffs.mtp"},
configPath: []string{"ffs.mtp"},
}

View File

@@ -80,7 +80,7 @@ func (u *UsbGadget) IsUDCBound() (bool, error) {
// BindUDC binds the gadget to the UDC. // BindUDC binds the gadget to the UDC.
func (u *UsbGadget) BindUDC() error { 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 { if err != nil {
return fmt.Errorf("error binding UDC: %w", err) return fmt.Errorf("error binding UDC: %w", err)
} }
@@ -89,7 +89,7 @@ func (u *UsbGadget) BindUDC() error {
// UnbindUDC unbinds the gadget from the UDC. // UnbindUDC unbinds the gadget from the UDC.
func (u *UsbGadget) UnbindUDC() error { 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 { if err != nil {
return fmt.Errorf("error unbinding UDC: %w", err) return fmt.Errorf("error unbinding UDC: %w", err)
} }

View File

@@ -20,6 +20,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"` RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"` Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"` MassStorage bool `json:"mass_storage"`
Mtp bool `json:"mtp"`
Audio bool `json:"audio"` Audio bool `json:"audio"`
} }
@@ -88,6 +89,9 @@ type UsbGadget struct {
const configFSPath = "/sys/kernel/config" const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget" const gadgetPath = "/sys/kernel/config/usb_gadget"
const functionFSPath = "/dev/ffs-mtp"
const umtprdPath = "/usr/sbin/umtprd"
var defaultLogger = logging.GetSubsystemLogger("usbgadget") var defaultLogger = logging.GetSubsystemLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget. // NewUsbGadget creates a new UsbGadget.

11
io.go
View File

@@ -58,7 +58,8 @@ func setGPIOValue(pin int, status bool) error {
} }
func setLedMode(ledConfigPath string, mode string) 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) err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err) return fmt.Errorf("failed to set LED trigger: %v", err)
@@ -71,7 +72,7 @@ func setLedMode(ledConfigPath string, mode string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED link: %v", err) 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) err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err) return fmt.Errorf("failed to set LED trigger: %v", err)
@@ -84,7 +85,7 @@ func setLedMode(ledConfigPath string, mode string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED tx: %v", err) 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) err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err) return fmt.Errorf("failed to set LED trigger: %v", err)
@@ -97,12 +98,12 @@ func setLedMode(ledConfigPath string, mode string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED rx: %v", err) 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) err := os.WriteFile(ledConfigPath+"/trigger", []byte("activity"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err) return fmt.Errorf("failed to set LED trigger: %v", err)
} }
} else { default:
return fmt.Errorf("invalid LED mode: %s", mode) return fmt.Errorf("invalid LED mode: %s", mode)
} }
return nil return nil

View File

@@ -793,6 +793,13 @@ func updateUsbRelatedConfig() error {
} }
func rpcSetUsbDevices(usbDevices usbgadget.Devices) 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 config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices) gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig() return updateUsbRelatedConfig()
@@ -978,6 +985,13 @@ func rpcSetAudioMode(mode string) error {
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }
if config.AudioMode != "disabled" {
StartNtpAudioServer(handleAudioClient)
} else {
StopNtpAudioServer()
}
return nil return nil
} }
@@ -1064,6 +1078,8 @@ var rpcHandlers = map[string]RPCHandler{
"getStorageSpace": {Func: rpcGetStorageSpace}, "getStorageSpace": {Func: rpcGetStorageSpace},
"getSDStorageSpace": {Func: rpcGetSDStorageSpace}, "getSDStorageSpace": {Func: rpcGetSDStorageSpace},
"resetSDStorage": {Func: rpcResetSDStorage}, "resetSDStorage": {Func: rpcResetSDStorage},
"mountSDStorage": {Func: rpcMountSDStorage},
"unmountSDStorage": {Func: rpcUnmountSDStorage},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}}, "mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
@@ -1113,4 +1129,9 @@ var rpcHandlers = map[string]RPCHandler{
"setUpdateSource": {Func: rpcSetUpdateSource, Params: []string{"source"}}, "setUpdateSource": {Func: rpcSetUpdateSource, Params: []string{"source"}},
"getAudioMode": {Func: rpcGetAudioMode}, "getAudioMode": {Func: rpcGetAudioMode},
"setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}}, "setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}},
"startFrpc": {Func: rpcStartFrpc, Params: []string{"frpcToml"}},
"stopFrpc": {Func: rpcStopFrpc},
"getFrpcStatus": {Func: rpcGetFrpcStatus},
"getFrpcToml": {Func: rpcGetFrpcToml},
"getFrpcLog": {Func: rpcGetFrpcLog},
} }

View File

@@ -14,6 +14,7 @@ import (
var appCtx context.Context var appCtx context.Context
func Main() { func Main() {
SyncConfigSD(true)
LoadConfig() LoadConfig()
var cancel context.CancelFunc var cancel context.CancelFunc
@@ -105,7 +106,6 @@ func Main() {
logger.Warn().Err(err).Msg("failed to extract and run vpn bin") logger.Warn().Err(err).Msg("failed to extract and run vpn bin")
//TODO: prepare an error message screen buffer to show on kvm screen //TODO: prepare an error message screen buffer to show on kvm screen
} }
}() }()
// initialize usb gadget // initialize usb gadget
@@ -129,6 +129,8 @@ func Main() {
// Initialize VPN // Initialize VPN
initVPN() initVPN()
initSystemInfo()
//Auto update //Auto update
//go func() { //go func() {
// time.Sleep(15 * time.Minute) // time.Sleep(15 * time.Minute)

8
ota.go
View File

@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest", "https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
} }
var builtAppVersion = "0.0.1+dev" var builtAppVersion = "0.0.2+dev"
var updateSource = "github" var updateSource = "github"
@@ -136,11 +136,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
appSha256 = release.Assets[0].Digest appSha256 = release.Assets[0].Digest
} }
// add sha256 prefix appSha256 = strings.TrimPrefix(appSha256, "sha256:")
if strings.HasPrefix(appSha256, "sha256:") {
// delete "sha256:"
appSha256 = appSha256[7:]
}
remoteMetadata := &RemoteMetadata{ remoteMetadata := &RemoteMetadata{
AppUrl: updateUrl, AppUrl: updateUrl,

50
ui/package-lock.json generated
View File

@@ -649,18 +649,30 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.1", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.14.0", "@eslint/core": "^0.15.2",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@floating-ui/core": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", "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": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2574,9 +2586,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -4234,6 +4246,18 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6764,9 +6788,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",

BIN
ui/src/assets/tailscale.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

BIN
ui/src/assets/zerotier.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -11,6 +11,7 @@ import {
useMountMediaStore, useMountMediaStore,
useSettingsStore, useSettingsStore,
useUiStore, useUiStore,
useAudioModeStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import Container from "@components/Container"; import Container from "@components/Container";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
@@ -41,9 +42,9 @@ export default function Actionbar({
const developerMode = useSettingsStore(state => state.developerMode); const developerMode = useSettingsStore(state => state.developerMode);
// Audio related // Audio related
const [audioMode, setAudioMode] = useState("disabled");
const [send] = useJsonRpc(); 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 // 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 // at time of writing this there is no mount, or unmount event for the popover
@@ -126,11 +127,11 @@ export default function Actionbar({
return ( return (
<> <>
<LuHardDrive className={className} /> <LuHardDrive className={className} />
<div {/*<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", { className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState, hidden: !remoteVirtualMediaState,
})} })}
/> />*/}
</> </>
); );
}} }}

View File

@@ -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<string, string | undefined> = {
'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<string, React.CSSProperties> = {};
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 (
<span className={className}>
{lines.map((ln, idx) => (
<div key={idx}>
{[...ln.text].map((ch, i) => (
<span key={i} style={ln.style[i]}>
{ch}
</span>
))}
</div>
))}
</span>
);
}
export function LogDialog({
open,
onClose,
title,
description,
variant = "info",
cancelText = "Cancel",
}: LogDialogProps) {
const { icon: Icon, iconClass, iconBgClass } = variantConfig[variant];
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-3 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-5 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-3">
<div className="sm:flex sm:items-start">
<div
className={cx(
"mx-auto flex size-11 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-5", iconClass)} />
</div>
<div className="mt-2 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h3>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
<Ansi>{description}</Ansi>
</div>
</div>
</div>
<div className="flex justify-end gap-x-1">
{cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
)}
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -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<UsbDeviceConfig>(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<HTMLSelectElement>) => {
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 (
<Fieldset disabled={loading} className="space-y-4">
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
loading={loading}
title="USB Other Function"
description="Select the active USB function (MTP or UAC)"
>
<SelectMenuBasic
size="SM"
label=""
value={usbEpMode}
fullWidth
onChange={handleUsbEpModeChange}
options={usbEpOptions}
/>
</SettingsItem>
{usbEpMode === "uac" && (
<SettingsItem
loading={loading}
title="Audio Mode"
badge="Experimental"
description="Set the working mode of the audio"
>
<SelectMenuBasic
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>
)}
</Fieldset>
);
}

View File

@@ -1,13 +1,16 @@
import React from "react"; import React from "react";
import { useCallback } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; 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 { BsMouseFill } from "react-icons/bs";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
interface OverlayContentProps { interface OverlayContentProps {
readonly children: React.ReactNode; readonly children: React.ReactNode;
@@ -215,6 +218,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal"; const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range"; 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 ( return (
<> <>
<AnimatePresence> <AnimatePresence>
@@ -245,8 +260,20 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
If using an adapter, ensure it&apos;s compatible and functioning If using an adapter, ensure it&apos;s compatible and functioning
correctly correctly
</li> </li>
<li>
Ensure source device is not in sleep mode and outputting a signal
</li>
</ul> </ul>
</div> </div>
<div>
<Button
theme="light"
text="Try Wakeup"
TrailingIcon={LuView}
size="SM"
onClick={onSendUsbWakeupSignal}
/>
</div>
<div> <div>
<LinkButton <LinkButton
to={"https://wiki.luckfox.com/intro"} to={"https://wiki.luckfox.com/intro"}

View File

@@ -1,5 +1,8 @@
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import TailscaleIcon from "@/assets/tailscale.png";
import ZeroTierIcon from "@/assets/zerotier.png";
const VpnConnectionStatusMap = { const VpnConnectionStatusMap = {
connected: "Connected", connected: "Connected",
connecting: "Connecting", connecting: "Connecting",
@@ -44,6 +47,24 @@ export default function VpnConnectionStatusCard({
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) return; if (!props) return;
const Icon = () => {
if (title === "ZeroTier") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-gray-300 dark:bg-gray-800">
<img src={ZeroTierIcon} alt="zerotier" className="h-4 w-4" />
</span>
);
}
if (title === "TailScale") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-gray-800 dark:bg-gray-800">
<img src={TailscaleIcon} alt="tailscale" className="h-4 w-4" />
</span>
);
}
return null;
};
return ( return (
<StatusCard <StatusCard
title={title || "Vpn Network"} title={title || "Vpn Network"}

View File

@@ -1,12 +1,14 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback, useState } from "react";
import { import {
LuArrowUpFromLine, LuArrowUpFromLine,
LuCheckCheck, LuCheckCheck,
LuLink, LuLink,
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
LuFileBadge,
LuFlagOff,
} from "react-icons/lu"; } from "react-icons/lu";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
@@ -14,11 +16,14 @@ import { useLocation } from "react-router-dom";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore, useUsbEpModeStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { SettingsItem } from "../../routes/devices.$id.settings";
import { UsbDeviceConfig } from "@components/UsbEpModeSetting";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
@@ -26,6 +31,29 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore(); 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(() => { const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null; if (diskDataChannelStats.size < 2) return null;
@@ -68,6 +96,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}); });
}; };
const handleUsbStorageModeChange = (value: string) => {
setUsbStorageMode(value);
}
const renderGridCardContent = () => { const renderGridCardContent = () => {
if (!remoteVirtualMediaState) { if (!remoteVirtualMediaState) {
return ( return (
@@ -187,7 +219,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
useEffect(() => { useEffect(() => {
syncRemoteVirtualMediaState(); syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, location.pathname]); getUsbEpMode();
}, [syncRemoteVirtualMediaState, location.pathname, getUsbEpMode]);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@@ -202,6 +235,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
description="Mount an image to boot from or install an operating system." description="Mount an image to boot from or install an operating system."
/> />
<SettingsItem
title="USB Storage Mode"
description=""
>
<SelectMenuBasic
size="SM"
label=""
value={usbStorageMode}
fullWidth
onChange={(e) => handleUsbStorageModeChange(e.target.value)}
options={usbStorageModeOptions}
/>
</SettingsItem>
{remoteVirtualMediaState?.source === "WebRTC" ? ( {remoteVirtualMediaState?.source === "WebRTC" ? (
<Card> <Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm"> <div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
@@ -213,81 +261,119 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card> </Card>
) : null} ) : null}
<div {usbStorageMode === "ums" && (
className="animate-fadeIn opacity-0 space-y-2" <div
style={{ className="animate-fadeIn opacity-0 space-y-2"
animationDuration: "0.7s", style={{
animationDelay: "0.1s", animationDuration: "0.7s",
}} animationDelay: "0.1s",
> }}
<div className="block select-none"> >
<div className="group"> <div className="block select-none">
<Card> <div className="group">
<div className="w-full px-4 py-8"> <Card>
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="w-full px-4 py-8">
{renderGridCardContent()} <div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()}
</div>
</div> </div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div> </div>
</Card>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
)}
{usbStorageMode === "mtp" && usbEpMode !== "mtp" && (
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<LuFlagOff className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
The MTP function has not been activated.
</h3>
</div>
</div>
</div>
</div>
</Card>
</div>
</div> </div>
</div> </div>
{remoteVirtualMediaState ? ( )}
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>
{!remoteVirtualMediaState && ( {!remoteVirtualMediaState && usbStorageMode === "ums" && (
<div <div
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
@@ -315,6 +401,36 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
/> />
</div> </div>
)} )}
{usbStorageMode === "mtp" && usbEpMode === "mtp" && (
<div
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Manager"
onClick={() => {
setModalView("mode");
navigateTo("/mtp");
}}
LeadingIcon={LuFileBadge}
/>
</div>
)}
</div> </div>
</GridCard> </GridCard>
); );

View File

@@ -434,7 +434,7 @@ export interface MountMediaState {
remoteVirtualMediaState: RemoteVirtualMediaState | null; remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void; 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; setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean; isMountMediaDialogOpen: boolean;
@@ -567,6 +567,26 @@ export const useHidStore = create<HidState>((set, get) => ({
setUsbState: state => set({ usbState: state }), setUsbState: state => set({ usbState: state }),
})); }));
export interface UsbEpModeStore {
usbEpMode: string;
setUsbEpMode: (mode: string) => void;
}
export const useUsbEpModeStore = create<UsbEpModeStore>(set => ({
usbEpMode: "disabled",
setUsbEpMode: (mode: string) => set({ usbEpMode: mode }),
}));
export interface AudioModeStore {
audioMode: string;
setAudioMode: (mode: string) => void;
}
export const useAudioModeStore = create<AudioModeStore>(set => ({
audioMode: "disabled",
setAudioMode: (mode: string) => set({ audioMode: mode }),
}));
export const useUserStore = create<UserState>(set => ({ export const useUserStore = create<UserState>(set => ({
user: null, user: null,
setUser: user => set({ user }), setUser: user => set({ user }),

View File

@@ -31,6 +31,7 @@ import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { DEVICE_API } from "./ui.config"; import { DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session"; import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount"; import MountRoute from "./routes/devices.$id.mount";
import MtpRoute from "./routes/devices.$id.mtp";
import * as SettingsRoute from "./routes/devices.$id.settings"; import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsMouseRoute from "./routes/devices.$id.settings.mouse"; import SettingsMouseRoute from "./routes/devices.$id.settings.mouse";
import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard"; import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard";
@@ -109,6 +110,10 @@ export async function checkAuth() {
path: "mount", path: "mount",
element: <MountRoute />, element: <MountRoute />,
}, },
{
path: "mtp",
element: <MtpRoute />,
},
{ {
path: "settings", path: "settings",
element: <SettingsRoute.default />, element: <SettingsRoute.default />,

View File

@@ -109,6 +109,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
function handleStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) { function handleStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${fileName} as ${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); setMountInProgress(true);
send("mountWithStorage", { filename: fileName, mode }, async resp => { send("mountWithStorage", { filename: fileName, mode }, async resp => {
if ("error" in resp) triggerError(resp.error.message); 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"]) { function handleSDStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${fileName} as ${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); setMountInProgress(true);
send("mountWithSDStorage", { filename: fileName, mode }, async resp => { send("mountWithSDStorage", { filename: fileName, mode }, async resp => {
if ("error" in resp) triggerError(resp.error.message); if ("error" in resp) triggerError(resp.error.message);
@@ -407,7 +417,7 @@ function ModeSelectionView({
<div <div
className="relative z-50 flex flex-col items-start p-4 select-none" className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() => onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") disabled ? null : setSelectedMode(mode as "browser" | "url" | "device" | "sd")
} }
> >
<div> <div>
@@ -1069,6 +1079,7 @@ function SDFileView({
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM"); const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const filesPerPage = 5; const filesPerPage = 5;
const [send] = useJsonRpc(); const [send] = useJsonRpc();
@@ -1196,17 +1207,37 @@ function SDFileView({
setCurrentPage(prev => Math.min(prev + 1, totalPages)); setCurrentPage(prev => Math.min(prev + 1, totalPages));
}; };
function handleResetSDStorage() { async function handleResetSDStorage() {
setLoading(true);
send("resetSDStorage", {}, res => { send("resetSDStorage", {}, res => {
console.log("Reset SD storage response:", res); console.log("Reset SD storage response:", res);
if ("error" in res) { if ("error" in res) {
notifications.error(`Failed to reset SD card`); notifications.error(`Failed to reset SD card`);
setLoading(false);
return; return;
} }
}); });
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncStorage(); 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") { if (sdMountStatus && sdMountStatus !== "ok") {
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
@@ -1223,6 +1254,7 @@ function SDFileView({
: "SD card mount failed"} : "SD card mount failed"}
<Button <Button
size="XS" size="XS"
disabled={loading}
theme="light" theme="light"
LeadingIcon={LuRefreshCw} LeadingIcon={LuRefreshCw}
onClick={handleResetSDStorage} onClick={handleResetSDStorage}
@@ -1268,6 +1300,7 @@ function SDFileView({
<div> <div>
<Button <Button
size="SM" size="SM"
disabled={loading}
theme="primary" theme="primary"
text="Upload a new image" text="Upload a new image"
onClick={() => onNewImageClick()} onClick={() => onNewImageClick()}
@@ -1312,14 +1345,14 @@ function SDFileView({
theme="light" theme="light"
text="Previous" text="Previous"
onClick={handlePreviousPage} onClick={handlePreviousPage}
disabled={currentPage === 1} disabled={currentPage === 1 || loading}
/> />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Next" text="Next"
onClick={handleNextPage} onClick={handleNextPage}
disabled={currentPage === totalPages} disabled={currentPage === totalPages || loading}
/> />
</div> </div>
</div> </div>
@@ -1344,7 +1377,7 @@ function SDFileView({
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} /> <Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button <Button
size="MD" size="MD"
disabled={selected === null || mountInProgress} disabled={selected === null || mountInProgress || loading}
theme="primary" theme="primary"
text="Mount File" text="Mount File"
loading={mountInProgress} loading={mountInProgress}
@@ -1412,6 +1445,7 @@ function SDFileView({
> >
<Button <Button
size="MD" size="MD"
disabled={loading}
theme="light" theme="light"
fullWidth fullWidth
text="Upload a new image" text="Upload a new image"
@@ -1419,6 +1453,24 @@ function SDFileView({
/> />
</div> </div>
)} )}
<div
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
}}
>
<Button
size="MD"
disabled={loading}
theme="light"
fullWidth
text="Unmount SD Card"
onClick={() => handleUnmountSDStorage()}
className="text-red-500 dark:text-red-400"
/>
</div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@ import { CloudState } from "./adopt";
import { useVpnStore } from "@/hooks/stores"; import { useVpnStore } from "@/hooks/stores";
import Checkbox from "../components/Checkbox"; import Checkbox from "../components/Checkbox";
import { LogDialog } from "../components/LogDialog";
export interface TailScaleResponse { export interface TailScaleResponse {
state: string; state: string;
loginUrl: string; loginUrl: string;
@@ -35,6 +37,11 @@ export interface ZeroTierResponse {
ip: string; ip: string;
} }
export interface FrpcResponse {
running: boolean;
}
export interface TLSState { export interface TLSState {
mode: "self-signed" | "custom" | "disabled"; mode: "self-signed" | "custom" | "disabled";
certificate?: string; certificate?: string;
@@ -84,6 +91,11 @@ export default function SettingsAccessIndexRoute() {
const [tempNetworkID, setTempNetworkID] = useState(""); const [tempNetworkID, setTempNetworkID] = useState("");
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const [frpcToml, setFrpcToml] = useState<string>("");
const [frpcLog, setFrpcLog] = useState<string>("");
const [showFrpcLogModal, setShowFrpcLogModal] = useState(false);
const [frpcStatus, setFrpcRunningStatus] = useState<FrpcResponse>({ running: false });
const getTLSState = useCallback(() => { const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => { send("getTLSState", {}, resp => {
if ("error" in resp) return console.error(resp.error); if ("error" in resp) return console.error(resp.error);
@@ -262,6 +274,77 @@ export default function SettingsAccessIndexRoute() {
}); });
},[send, zeroTierNetworkID]); },[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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@@ -399,16 +482,6 @@ export default function SettingsAccessIndexRoute() {
badge="Experimental" badge="Experimental"
description="Connect to TailScale VPN network" description="Connect to TailScale VPN network"
> >
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Enable TailScale"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
title="" title=""
@@ -425,6 +498,21 @@ export default function SettingsAccessIndexRoute() {
}} }}
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title=""
description=""
>
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Enable"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -558,7 +646,58 @@ export default function SettingsAccessIndexRoute() {
)} )}
</div> </div>
<div className="space-y-4">
<SettingsItem
title="Frp"
description="Connect to Frp Server"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Edit frpc.toml"
placeholder="Enter frpc settings"
value={frpcToml || ""}
rows={3}
onChange={e => setFrpcToml(e.target.value)}
/>
<div className="flex items-center gap-x-2">
{frpcStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text="Stop frpc"
onClick={handleStopFrpc}
/>
<Button
size="SM"
theme="light"
text="Log"
onClick={handleGetFrpcLog}
/>
</div>
) : (
<Button
size="SM"
theme="primary"
text="Start frpc"
onClick={handleStartFrpc}
/>
)}
</div>
</div>
</div> </div>
</div>
<LogDialog
open={showFrpcLogModal}
onClose={() => {
setShowFrpcLogModal(false);
}}
title="Frpc Log"
description={frpcLog}
/>
</div> </div>
); );
} }

View File

@@ -5,13 +5,11 @@ import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { InputField } from "@/components/InputField"; import { InputField } from "@/components/InputField";
import { Button, LinkButton } from "@/components/Button"; import { Button, LinkButton } from "@/components/Button";
import notifications from "../notifications"; import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbEpModeSetting } from "@components/UsbEpModeSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() { export default function SettingsHardwareRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
@@ -333,13 +331,9 @@ export default function SettingsHardwareRoute() {
</div> </div>
<FeatureFlag minAppVersion="0.3.8"> <UsbEpModeSetting />
<UsbDeviceSetting /> {/*<UsbDeviceSetting /> */}
</FeatureFlag> {/*<UsbInfoSetting /> */}
<FeatureFlag minAppVersion="0.3.8">
<UsbInfoSetting />
</FeatureFlag>
</div> </div>
); );
} }

View File

@@ -40,16 +40,9 @@ const streamQualityOptions = [
{ value: "0.1", label: "Low" }, { value: "0.1", label: "Low" },
]; ];
const audioModeOptions = [
{ value: "disabled", label: "Disabled"},
{ value: "usb", label: "USB"},
//{ value: "hdmi", label: "HDMI"},
]
export default function SettingsVideoRoute() { export default function SettingsVideoRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1"); const [streamQuality, setStreamQuality] = useState("1");
const [audioMode, setAudioMode] = useState("disabled");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null); const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
@@ -62,11 +55,6 @@ export default function SettingsVideoRoute() {
const setVideoContrast = useSettingsStore(state => state.setVideoContrast); const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
useEffect(() => { useEffect(() => {
send("getAudioMode", {}, resp => {
if ("error" in resp) return;
setAudioMode(String(resp.result));
});
send("getStreamQualityFactor", {}, resp => { send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return; if ("error" in resp) return;
setStreamQuality(String(resp.result)); setStreamQuality(String(resp.result));
@@ -96,20 +84,6 @@ export default function SettingsVideoRoute() {
}); });
}, [send]); }, [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) => { const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => { send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) { if ("error" in resp) {
@@ -149,20 +123,6 @@ export default function SettingsVideoRoute() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem
title="Audio Mode"
badge="Experimental"
description="Set the working mode of the audio"
>
<SelectMenuBasic
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem <SettingsItem
title="Stream Quality" title="Stream Quality"
description="Adjust the quality of the video stream" description="Adjust the quality of the video stream"

View File

@@ -233,7 +233,8 @@ export default function KvmIdRoute() {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket( const { sendMessage, getWebSocket } = useWebSocket(
`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`, //`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
`${wsProtocol}//${window.location.host}/webrtc/signaling/client`,
{ {
heartbeat: true, heartbeat: true,
retryOnError: true, retryOnError: true,
@@ -362,9 +363,18 @@ export default function KvmIdRoute() {
setLoadingMessage("Creating peer connection..."); setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({ pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud // We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers //...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] } // ? { iceServers: [iceConfig?.iceServers] }
: {}), // : {}),
...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
}),
}); });
setPeerConnectionState(pc.connectionState); setPeerConnectionState(pc.connectionState);

39
usb.go
View File

@@ -12,6 +12,17 @@ var gadget *usbgadget.UsbGadget
// initUsbGadget initializes the USB gadget. // initUsbGadget initializes the USB gadget.
// call it only after the config is loaded. // call it only after the config is loaded.
func initUsbGadget() { func initUsbGadget() {
resp, _ := rpcGetSDMountStatus()
if resp.Status == SDMountOK {
if err := writeUmtprdConfFile(true); err != nil {
usbLogger.Error().Err(err).Msg("failed to write umtprd conf file")
}
} else {
if err := writeUmtprdConfFile(false); err != nil {
usbLogger.Error().Err(err).Msg("failed to write umtprd conf file")
}
}
gadget = usbgadget.NewUsbGadget( gadget = usbgadget.NewUsbGadget(
"kvm", "kvm",
config.UsbDevices, config.UsbDevices,
@@ -38,6 +49,34 @@ func initUsbGadget() {
} }
} }
func initSystemInfo() {
go func() {
for {
if !networkState.HasIPAssigned() {
vpnLogger.Warn().Msg("waiting for network get IPv4 address, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
} else {
break
}
}
err := writeSystemInfoImg()
if err != nil {
usbLogger.Error().Err(err).Msg("failed to create system_info.img")
}
mediaState, _ := rpcGetVirtualMediaState()
if mediaState != nil && mediaState.Filename == "system_info.img" {
usbLogger.Error().Err(err).Msg("system_info.img is busy")
} else if mediaState == nil || mediaState.Filename == "" {
err = rpcMountWithStorage("system_info.img", Disk)
if err != nil {
usbLogger.Error().Err(err).Msg("failed to mount system_info.img")
}
}
}()
}
func rpcKeyboardReport(modifier uint8, keys []uint8) error { func rpcKeyboardReport(modifier uint8, keys []uint8) error {
return gadget.KeyboardReport(modifier, keys) return gadget.KeyboardReport(modifier, keys)
} }

View File

@@ -93,7 +93,7 @@ func mountImage(imagePath string) error {
var nbdDevice *NBDDevice var nbdDevice *NBDDevice
const imagesFolder = "/userdata/picokvm/images" const imagesFolder = "/userdata/picokvm/share"
const SDImagesFolder = "/mnt/sdcard" const SDImagesFolder = "/mnt/sdcard"
func initImagesFolder() error { func initImagesFolder() error {
@@ -402,7 +402,7 @@ func rpcMountWithSDStorage(filename string, mode VirtualMediaMode) error {
fullPath := filepath.Join(SDImagesFolder, filename) fullPath := filepath.Join(SDImagesFolder, filename)
fileInfo, err := os.Stat(fullPath) fileInfo, err := os.Stat(fullPath)
if err != nil { if err != nil {
return fmt.Errorf("[rpcMountWithStorage]failed to get file info: %w", err) return fmt.Errorf("[rpcMountWithSDStorage]failed to get file info: %w", err)
} }
if err := setMassStorageMode(mode == CDROM); err != nil { if err := setMassStorageMode(mode == CDROM); err != nil {
@@ -694,6 +694,25 @@ func handleUploadHttp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Upload completed"}) c.JSON(http.StatusOK, gin.H{"message": "Upload completed"})
} }
func handleDownloadHttp(c *gin.Context) {
filename := c.Query("file")
if filename == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file parameter"})
return
}
sanitizedFilename, err := sanitizeFilename(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
fullPath := filepath.Join(imagesFolder, sanitizedFilename)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizedFilename))
c.FileAttachment(fullPath, sanitizedFilename)
}
// SD Card // SD Card
func rpcGetSDStorageSpace() (*StorageSpace, error) { func rpcGetSDStorageSpace() (*StorageSpace, error) {
var stat syscall.Statfs_t var stat syscall.Statfs_t
@@ -715,13 +734,50 @@ func rpcGetSDStorageSpace() (*StorageSpace, error) {
func rpcResetSDStorage() error { func rpcResetSDStorage() error {
err := os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/unbind", []byte("ffaa0000.mmc"), 0644) err := os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/unbind", []byte("ffaa0000.mmc"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to write to file: %v", err) logger.Error().Err(err).Msg("failed to unbind mmc")
} }
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
err = os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/bind", []byte("ffaa0000.mmc"), 0644) err = os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/bind", []byte("ffaa0000.mmc"), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to write to file: %v", err) logger.Error().Err(err).Msg("failed to bind mmc")
} }
time.Sleep(500 * time.Millisecond)
err = updateMtpWithSDStatus()
if err != nil {
return err
}
return nil
}
func rpcMountSDStorage() error {
err := os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/bind", []byte("ffaa0000.mmc"), 0644)
if err != nil {
logger.Error().Err(err).Msg("failed to bind mmc")
}
time.Sleep(500 * time.Millisecond)
err = updateMtpWithSDStatus()
if err != nil {
return err
}
return nil
}
func rpcUnmountSDStorage() error {
err := os.WriteFile("/sys/bus/platform/drivers/dwmmc_rockchip/unbind", []byte("ffaa0000.mmc"), 0644)
if err != nil {
logger.Error().Err(err).Msg("failed to unbind mmc")
}
time.Sleep(500 * time.Millisecond)
err = updateMtpWithSDStatus()
if err != nil {
return err
}
return nil return nil
} }
@@ -861,3 +917,106 @@ func rpcStartSDStorageFileUpload(filename string, size int64) (*SDStorageFileUpl
DataChannel: uploadId, DataChannel: uploadId,
}, nil }, nil
} }
func handleSDDownloadHttp(c *gin.Context) {
filename := c.Query("file")
if filename == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file parameter"})
return
}
sanitizedFilename, err := sanitizeFilename(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
fullPath := filepath.Join(SDImagesFolder, sanitizedFilename)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizedFilename))
c.FileAttachment(fullPath, sanitizedFilename)
}
const umtprdConfPath = "/etc/umtprd/umtprd.conf"
var umtprdWriteLock sync.Mutex
func writeUmtprdConfFile(withSD bool) error {
umtprdWriteLock.Lock()
defer umtprdWriteLock.Unlock()
if err := os.MkdirAll(filepath.Dir(umtprdConfPath), 0755); err != nil {
return fmt.Errorf("failed to create umtprd.conf dir: %w", err)
}
conf := `loop_on_disconnect 1
storage "/userdata/picokvm/share" "share folder" "rw"
`
if withSD {
conf += `storage "/mnt/sdcard" "sdcard folder" "rw"
`
}
conf += fmt.Sprintf(`
manufacturer "%s"
product "%s"
serial "%s"
usb_vendor_id %s
usb_product_id %s
usb_functionfs_mode 0x1
usb_dev_path "/dev/ffs-mtp/ep0"
usb_epin_path "/dev/ffs-mtp/ep1"
usb_epout_path "/dev/ffs-mtp/ep2"
usb_epint_path "/dev/ffs-mtp/ep3"
usb_max_packet_size 0x200
`,
config.UsbConfig.Manufacturer,
config.UsbConfig.Product,
config.UsbConfig.SerialNumber,
config.UsbConfig.VendorId,
config.UsbConfig.ProductId,
)
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
}
func updateMtpWithSDStatus() error {
resp, _ := rpcGetSDMountStatus()
if resp.Status == SDMountOK {
if err := writeUmtprdConfFile(true); err != nil {
logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
} else {
if err := writeUmtprdConfFile(false); err != nil {
logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
}
if config.UsbDevices.Mtp {
if err := gadget.UnbindUDC(); err != nil {
logger.Error().Err(err).Msg("failed to unbind UDC")
}
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 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
}

168
vpn.go
View File

@@ -1,8 +1,12 @@
package kvm package kvm
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec"
"path/filepath"
"syscall" "syscall"
"time" "time"
) )
@@ -30,8 +34,6 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) {
IP: "", IP: "",
} }
//fmt.Printf("[rpcLoginTailScale] xEdge: %v\n", xEdge)
_, err := CallVpnCtrlAction("login_tailscale", map[string]interface{}{"xEdge": xEdge}) _, err := CallVpnCtrlAction("login_tailscale", map[string]interface{}{"xEdge": xEdge})
if err != nil { if err != nil {
return settings, err return settings, err
@@ -63,13 +65,19 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) {
case "logined": case "logined":
config.TailScaleAutoStart = true config.TailScaleAutoStart = true
config.TailScaleXEdge = settings.XEdge config.TailScaleXEdge = settings.XEdge
SaveConfig() err := SaveConfig()
return settings, nil if err != nil {
vpnLogger.Error().Err(err).Msg("failed to save config")
}
return settings, err
case "connected": case "connected":
config.TailScaleAutoStart = true config.TailScaleAutoStart = true
config.TailScaleXEdge = settings.XEdge config.TailScaleXEdge = settings.XEdge
SaveConfig() err = SaveConfig()
return settings, nil if err != nil {
vpnLogger.Error().Err(err).Msg("failed to save config")
}
return settings, err
case "connecting": case "connecting":
if i >= 10 { if i >= 10 {
settings.State = "disconnected" settings.State = "disconnected"
@@ -77,7 +85,10 @@ func rpcLoginTailScale(xEdge bool) (TailScaleSettings, error) {
settings.State = "connecting" settings.State = "connecting"
} }
case "cancel": case "cancel":
go rpcLogoutTailScale() err := rpcLogoutTailScale()
if err != nil {
vpnLogger.Error().Err(err).Msg("failed to logout tailscale")
}
settings.State = "disconnected" settings.State = "disconnected"
return settings, nil return settings, nil
default: default:
@@ -135,7 +146,6 @@ type ZeroTierSettings struct {
func rpcLoginZeroTier(networkID string) (ZeroTierSettings, error) { func rpcLoginZeroTier(networkID string) (ZeroTierSettings, error) {
LoadConfig() LoadConfig()
//fmt.Printf("[rpcLoginZeroTier] networkID: %s\n", networkID)
settings := ZeroTierSettings{ settings := ZeroTierSettings{
State: "connecting", State: "connecting",
NetworkID: networkID, 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.ZeroTierAutoStart = false
config.ZeroTierNetworkID = "" config.ZeroTierNetworkID = ""
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
vpnLogger.Error().Err(err).Msg("failed to save config") vpnLogger.Error().Err(err).Msg("failed to save config")
} }
} else if settings.State == "connected" || settings.State == "logined" { case "connected", "logined":
config.ZeroTierAutoStart = true config.ZeroTierAutoStart = true
config.ZeroTierNetworkID = settings.NetworkID config.ZeroTierNetworkID = settings.NetworkID
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
@@ -243,27 +254,146 @@ func HandleVpnDisplayUpdateMessage(event CtrlResponse) {
return return
} }
if vpnUpdateDisplayState.TailScaleState == "connected" { switch vpnUpdateDisplayState.TailScaleState {
case "connected":
updateLabelIfChanged("Network_TailScale_Label", "Connected") updateLabelIfChanged("Network_TailScale_Label", "Connected")
} else if vpnUpdateDisplayState.TailScaleState == "logined" { case "logined":
updateLabelIfChanged("Network_TailScale_Label", "Logined") updateLabelIfChanged("Network_TailScale_Label", "Logined")
} else { default:
updateLabelIfChanged("Network_TailScale_Label", "Disconnected") updateLabelIfChanged("Network_TailScale_Label", "Disconnected")
} }
if vpnUpdateDisplayState.ZeroTierState == "connected" { switch vpnUpdateDisplayState.ZeroTierState {
case "connected":
updateLabelIfChanged("Network_ZeroTier_Label", "Connected") updateLabelIfChanged("Network_ZeroTier_Label", "Connected")
} else if vpnUpdateDisplayState.ZeroTierState == "logined" { case "logined":
updateLabelIfChanged("Network_ZeroTier_Label", "Logined") updateLabelIfChanged("Network_ZeroTier_Label", "Logined")
} else { default:
updateLabelIfChanged("Network_ZeroTier_Label", "Disconnected") 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() { func initVPN() {
waitVpnCtrlClientConnected() waitVpnCtrlClientConnected()
go func() { 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 config.TailScaleAutoStart {
if _, err := rpcLoginTailScale(config.TailScaleXEdge); err != nil { if _, err := rpcLoginTailScale(config.TailScaleXEdge); err != nil {
vpnLogger.Error().Err(err).Msg("Failed to auto start TailScale") 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") 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() { go func() {

17
web.go
View File

@@ -158,6 +158,8 @@ func setupRouter() *gin.Engine {
protected.PUT("/auth/password-local", handleUpdatePassword) protected.PUT("/auth/password-local", handleUpdatePassword)
protected.DELETE("/auth/local-password", handleDeletePassword) protected.DELETE("/auth/local-password", handleDeletePassword)
protected.POST("/storage/upload", handleUploadHttp) protected.POST("/storage/upload", handleUploadHttp)
protected.GET("/storage/download", handleDownloadHttp)
protected.GET("/storage/sd-download", handleSDDownloadHttp)
} }
// Catch-all route for SPA // Catch-all route for SPA
@@ -214,12 +216,17 @@ var (
func handleLocalWebRTCSignal(c *gin.Context) { func handleLocalWebRTCSignal(c *gin.Context) {
// get the source from the request // get the source from the request
source := c.ClientIP() source := c.ClientIP()
connectionID := uuid.New().String()
connectionID := c.Query("id")
if connectionID == "" {
connectionID = uuid.New().String()
}
scopedLogger := websocketLogger.With(). scopedLogger := websocketLogger.With().
Str("component", "websocket"). Str("component", "websocket").
Str("source", source). Str("source", source).
Str("sourceType", "local"). Str("sourceType", "local").
Str("connectionID", connectionID).
Logger() Logger()
scopedLogger.Info().Msg("new websocket connection established") scopedLogger.Info().Msg("new websocket connection established")
@@ -246,7 +253,10 @@ func handleLocalWebRTCSignal(c *gin.Context) {
// Now use conn for websocket operations // Now use conn for websocket operations
defer wsCon.Close(websocket.StatusNormalClosure, "") 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -271,8 +281,7 @@ func handleWebRTCSignalWsMessages(
}() }()
// connection type // connection type
var sourceType string var sourceType = "local"
sourceType = "local"
l := scopedLogger.With(). l := scopedLogger.With().
Str("source", source). Str("source", source).

View File

@@ -68,23 +68,38 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) {
return base64.StdEncoding.EncodeToString(localDescription), nil return base64.StdEncoding.EncodeToString(localDescription), nil
} }
func newSession(config SessionConfig) (*Session, error) { func newSession(sessionConfig SessionConfig) (*Session, error) {
webrtcSettingEngine := webrtc.SettingEngine{ webrtcSettingEngine := webrtc.SettingEngine{
LoggerFactory: logging.GetPionDefaultLoggerFactory(), LoggerFactory: logging.GetPionDefaultLoggerFactory(),
} }
iceServer := webrtc.ICEServer{} webrtcSettingEngine.SetNetworkTypes([]webrtc.NetworkType{
webrtc.NetworkTypeUDP4,
webrtc.NetworkTypeUDP6,
})
//iceServer := webrtc.ICEServer{}
var scopedLogger *zerolog.Logger var scopedLogger *zerolog.Logger
if config.Logger != nil { if sessionConfig.Logger != nil {
l := config.Logger.With().Str("component", "webrtc").Logger() l := sessionConfig.Logger.With().Str("component", "webrtc").Logger()
scopedLogger = &l scopedLogger = &l
} else { } else {
scopedLogger = webrtcLogger 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)) api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{ peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{iceServer}, ICEServers: iceServers,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -162,7 +177,7 @@ func newSession(config SessionConfig) (*Session, error) {
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate") scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate")
if candidate != nil { 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 { if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel") scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel")
} }