mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-18 03:28:19 +01:00
Update App version to 0.0.2
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
@@ -5,6 +5,8 @@ linters:
|
|||||||
- misspell
|
- misspell
|
||||||
- whitespace
|
- whitespace
|
||||||
- gochecknoinits
|
- gochecknoinits
|
||||||
|
disable:
|
||||||
|
- unused
|
||||||
settings:
|
settings:
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
forbid:
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"tailwindCSS.classFunctions": ["cva", "cx"]
|
|
||||||
}
|
|
||||||
4
Makefile
4
Makefile
@@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
|||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.0.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
|
||||||
|
|||||||
7
audio.go
7
audio.go
@@ -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")
|
||||||
|
|||||||
3
cloud.go
3
cloud.go
@@ -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
190
config.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type NetworkConfig struct {
|
|||||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||||
|
|
||||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||||
|
IPv4RequestAddress null.String `json:"ipv4_request_address,omitempty"`
|
||||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
||||||
|
|
||||||
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -24,6 +27,7 @@ type DHCPClient struct {
|
|||||||
InterfaceName string
|
InterfaceName string
|
||||||
leaseFile string
|
leaseFile string
|
||||||
pidFile string
|
pidFile string
|
||||||
|
requestAddress string
|
||||||
lease *Lease
|
lease *Lease
|
||||||
logger *zerolog.Logger
|
logger *zerolog.Logger
|
||||||
process *os.Process
|
process *os.Process
|
||||||
@@ -35,6 +39,7 @@ type DHCPClientOptions struct {
|
|||||||
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)
|
||||||
@@ -51,6 +56,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
|||||||
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() {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,9 +330,19 @@ 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")
|
||||||
|
if mtpServer {
|
||||||
|
tx.addFileChange("udc", RequestedFileChange{
|
||||||
|
Key: "udc",
|
||||||
|
Path: path,
|
||||||
|
ExpectedState: FileStateFileContentMatch,
|
||||||
|
ExpectedContent: []byte(tx.udc),
|
||||||
|
DependsOn: []string{"mtp"},
|
||||||
|
Description: "write UDC",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
tx.addFileChange("udc", RequestedFileChange{
|
tx.addFileChange("udc", RequestedFileChange{
|
||||||
Key: "udc",
|
Key: "udc",
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -326,6 +351,7 @@ func (tx *UsbGadgetTransaction) WriteUDC() {
|
|||||||
DependsOn: []string{"reorder-symlinks"},
|
DependsOn: []string{"reorder-symlinks"},
|
||||||
Description: "write UDC",
|
Description: "write UDC",
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
|
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
io.go
@@ -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
|
||||||
|
|||||||
21
jsonrpc.go
21
jsonrpc.go
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -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
8
ota.go
@@ -59,7 +59,7 @@ var UpdateMetadataUrls = []string{
|
|||||||
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
|
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
|
||||||
}
|
}
|
||||||
|
|
||||||
var builtAppVersion = "0.0.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
50
ui/package-lock.json
generated
@@ -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
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
BIN
ui/src/assets/zerotier.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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,
|
||||||
})}
|
})}
|
||||||
/>
|
/>*/}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
190
ui/src/components/LogDialog.tsx
Normal file
190
ui/src/components/LogDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
ui/src/components/UsbEpModeSetting.tsx
Normal file
196
ui/src/components/UsbEpModeSetting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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's compatible and functioning
|
If using an adapter, ensure it'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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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,6 +261,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{usbStorageMode === "ums" && (
|
||||||
<div
|
<div
|
||||||
className="animate-fadeIn opacity-0 space-y-2"
|
className="animate-fadeIn opacity-0 space-y-2"
|
||||||
style={{
|
style={{
|
||||||
@@ -283,11 +332,48 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1626
ui/src/routes/devices.$id.mtp.tsx
Normal file
1626
ui/src/routes/devices.$id.mtp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text="Start frpc"
|
||||||
|
onClick={handleStartFrpc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LogDialog
|
||||||
|
open={showFrpcLogModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowFrpcLogModal(false);
|
||||||
|
}}
|
||||||
|
title="Frpc Log"
|
||||||
|
description={frpcLog}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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] }
|
||||||
|
// : {}),
|
||||||
|
...(iceConfig?.iceServers
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
: {}),
|
: {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: ['stun:stun.l.google.com:19302']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
|
|||||||
39
usb.go
39
usb.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
168
vpn.go
@@ -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
17
web.go
@@ -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).
|
||||||
|
|||||||
27
webrtc.go
27
webrtc.go
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user