mirror of https://github.com/jetkvm/kvm.git
Merge b9c871cd63
into 951173ba19
This commit is contained in:
commit
12657fff6e
4
Makefile
4
Makefile
|
@ -1,5 +1,5 @@
|
|||
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.3.4
|
||||
VERSION_DEV := 0.3.6-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.3.5
|
||||
|
||||
hash_resource:
|
||||
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
|
||||
|
|
|
@ -0,0 +1,300 @@
|
|||
package jsonrpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type JSONRPCRouter struct {
|
||||
writer io.Writer
|
||||
|
||||
handlers map[string]*RPCHandler
|
||||
nextId atomic.Int64
|
||||
|
||||
responseChannelsMutex sync.Mutex
|
||||
responseChannels map[int64]chan JSONRPCResponse
|
||||
}
|
||||
|
||||
func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCRouter {
|
||||
return &JSONRPCRouter{
|
||||
writer: writer,
|
||||
handlers: handlers,
|
||||
|
||||
responseChannels: make(map[int64]chan JSONRPCResponse),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *JSONRPCRouter) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError {
|
||||
id := s.nextId.Add(1)
|
||||
request := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
Params: params,
|
||||
ID: id,
|
||||
}
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return &JSONRPCResponseError{
|
||||
Code: -32700,
|
||||
Message: "Parse error",
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
// log.Printf("Sending RPC request: Method=%s, Params=%v, ID=%d", method, params, id)
|
||||
|
||||
responseChan := make(chan JSONRPCResponse, 1)
|
||||
s.responseChannelsMutex.Lock()
|
||||
s.responseChannels[id] = responseChan
|
||||
s.responseChannelsMutex.Unlock()
|
||||
defer func() {
|
||||
s.responseChannelsMutex.Lock()
|
||||
delete(s.responseChannels, id)
|
||||
s.responseChannelsMutex.Unlock()
|
||||
}()
|
||||
|
||||
_, err = s.writer.Write(requestBytes)
|
||||
if err != nil {
|
||||
return &JSONRPCResponseError{
|
||||
Code: -32603,
|
||||
Message: "Internal error",
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.After(5 * time.Second)
|
||||
select {
|
||||
case response := <-responseChan:
|
||||
if response.Error != nil {
|
||||
return response.Error
|
||||
}
|
||||
|
||||
rawResult, err := json.Marshal(response.Result)
|
||||
if err != nil {
|
||||
return &JSONRPCResponseError{
|
||||
Code: -32603,
|
||||
Message: "Internal error",
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(rawResult, result); err != nil {
|
||||
return &JSONRPCResponseError{
|
||||
Code: -32603,
|
||||
Message: "Internal error",
|
||||
Data: err,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
case <-timeout:
|
||||
return &JSONRPCResponseError{
|
||||
Code: -32603,
|
||||
Message: "Internal error",
|
||||
Data: "timeout waiting for response",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type JSONRPCMessage struct {
|
||||
Method *string `json:"method,omitempty"`
|
||||
ID *int64 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *JSONRPCRouter) HandleMessage(data []byte) error {
|
||||
// Data will either be a JSONRPCRequest or JSONRPCResponse object
|
||||
// We need to determine which one it is
|
||||
var raw JSONRPCMessage
|
||||
err := json.Unmarshal(data, &raw)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &JSONRPCResponseError{
|
||||
Code: -32700,
|
||||
Message: "Parse error",
|
||||
},
|
||||
ID: 0,
|
||||
}
|
||||
return s.writeResponse(errorResponse)
|
||||
}
|
||||
|
||||
if raw.Method == nil && raw.ID != nil {
|
||||
var resp JSONRPCResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
fmt.Println("error unmarshalling response", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.responseChannelsMutex.Lock()
|
||||
responseChan, ok := s.responseChannels[*raw.ID]
|
||||
s.responseChannelsMutex.Unlock()
|
||||
if ok {
|
||||
responseChan <- resp
|
||||
} else {
|
||||
log.Println("No response channel found for ID", resp.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var request JSONRPCRequest
|
||||
err = json.Unmarshal(data, &request)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &JSONRPCResponseError{
|
||||
Code: -32700,
|
||||
Message: "Parse error",
|
||||
},
|
||||
ID: 0,
|
||||
}
|
||||
return s.writeResponse(errorResponse)
|
||||
}
|
||||
|
||||
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
|
||||
handler, ok := s.handlers[request.Method]
|
||||
if !ok {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &JSONRPCResponseError{
|
||||
Code: -32601,
|
||||
Message: "Method not found",
|
||||
},
|
||||
ID: request.ID,
|
||||
}
|
||||
return s.writeResponse(errorResponse)
|
||||
}
|
||||
|
||||
result, err := callRPCHandler(handler, request.Params)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &JSONRPCResponseError{
|
||||
Code: -32603,
|
||||
Message: "Internal error",
|
||||
Data: err.Error(),
|
||||
},
|
||||
ID: request.ID,
|
||||
}
|
||||
return s.writeResponse(errorResponse)
|
||||
}
|
||||
|
||||
response := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Result: result,
|
||||
ID: request.ID,
|
||||
}
|
||||
return s.writeResponse(response)
|
||||
}
|
||||
|
||||
func (s *JSONRPCRouter) writeResponse(response JSONRPCResponse) error {
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.writer.Write(responseBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func callRPCHandler(handler *RPCHandler, params map[string]interface{}) (interface{}, error) {
|
||||
handlerValue := reflect.ValueOf(handler.Func)
|
||||
handlerType := handlerValue.Type()
|
||||
|
||||
if handlerType.Kind() != reflect.Func {
|
||||
return nil, errors.New("handler is not a function")
|
||||
}
|
||||
|
||||
numParams := handlerType.NumIn()
|
||||
args := make([]reflect.Value, numParams)
|
||||
// Get the parameter names from the RPCHandler
|
||||
paramNames := handler.Params
|
||||
|
||||
if len(paramNames) != numParams {
|
||||
return nil, errors.New("mismatch between handler parameters and defined parameter names")
|
||||
}
|
||||
|
||||
for i := 0; i < numParams; i++ {
|
||||
paramType := handlerType.In(i)
|
||||
paramName := paramNames[i]
|
||||
paramValue, ok := params[paramName]
|
||||
if !ok {
|
||||
return nil, errors.New("missing parameter: " + paramName)
|
||||
}
|
||||
|
||||
convertedValue := reflect.ValueOf(paramValue)
|
||||
if !convertedValue.Type().ConvertibleTo(paramType) {
|
||||
if paramType.Kind() == reflect.Slice && (convertedValue.Kind() == reflect.Slice || convertedValue.Kind() == reflect.Array) {
|
||||
newSlice := reflect.MakeSlice(paramType, convertedValue.Len(), convertedValue.Len())
|
||||
for j := 0; j < convertedValue.Len(); j++ {
|
||||
elemValue := convertedValue.Index(j)
|
||||
if elemValue.Kind() == reflect.Interface {
|
||||
elemValue = elemValue.Elem()
|
||||
}
|
||||
if !elemValue.Type().ConvertibleTo(paramType.Elem()) {
|
||||
// Handle float64 to uint8 conversion
|
||||
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
|
||||
intValue := int(elemValue.Float())
|
||||
if intValue < 0 || intValue > 255 {
|
||||
return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
|
||||
}
|
||||
newSlice.Index(j).SetUint(uint64(intValue))
|
||||
} else {
|
||||
fromType := elemValue.Type()
|
||||
toType := paramType.Elem()
|
||||
return nil, fmt.Errorf("invalid element type in slice for parameter %s: from %v to %v", paramName, fromType, toType)
|
||||
}
|
||||
} else {
|
||||
newSlice.Index(j).Set(elemValue.Convert(paramType.Elem()))
|
||||
}
|
||||
}
|
||||
args[i] = newSlice
|
||||
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
|
||||
jsonData, err := json.Marshal(convertedValue.Interface())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
|
||||
}
|
||||
|
||||
newStruct := reflect.New(paramType).Interface()
|
||||
if err := json.Unmarshal(jsonData, newStruct); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
|
||||
}
|
||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
|
||||
}
|
||||
} else {
|
||||
args[i] = convertedValue.Convert(paramType)
|
||||
}
|
||||
}
|
||||
|
||||
results := handlerValue.Call(args)
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(results) == 1 {
|
||||
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[0].IsNil() {
|
||||
return nil, results[0].Interface().(error)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
|
||||
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[1].IsNil() {
|
||||
return nil, results[1].Interface().(error)
|
||||
}
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
|
||||
return nil, errors.New("unexpected return values from handler")
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package jsonrpc
|
||||
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *JSONRPCResponseError `json:"error,omitempty"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
type JSONRPCResponseError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCEvent struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type RPCHandler struct {
|
||||
Func interface{}
|
||||
Params []string
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const databaseFile = pluginsFolder + "/plugins.json"
|
||||
|
||||
type PluginDatabase struct {
|
||||
// Map with the plugin name as the key
|
||||
Plugins map[string]*PluginInstall `json:"plugins"`
|
||||
|
||||
saveMutex sync.Mutex
|
||||
}
|
||||
|
||||
var pluginDatabase = PluginDatabase{}
|
||||
|
||||
func (d *PluginDatabase) Load() error {
|
||||
file, err := os.Open(databaseFile)
|
||||
if os.IsNotExist(err) {
|
||||
d.Plugins = make(map[string]*PluginInstall)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open plugin database: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := json.NewDecoder(file).Decode(d); err != nil {
|
||||
return fmt.Errorf("failed to decode plugin database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PluginDatabase) Save() error {
|
||||
d.saveMutex.Lock()
|
||||
defer d.saveMutex.Unlock()
|
||||
|
||||
file, err := os.Create(databaseFile + ".tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create plugin database tmp: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(d); err != nil {
|
||||
return fmt.Errorf("failed to encode plugin database: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(databaseFile+".tmp", databaseFile); err != nil {
|
||||
return fmt.Errorf("failed to move plugin database to active file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find all extract directories that are not referenced in the Plugins map and remove them
|
||||
func (d *PluginDatabase) CleanupExtractDirectories() error {
|
||||
extractDirectories, err := os.ReadDir(pluginsExtractsFolder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read extract directories: %v", err)
|
||||
}
|
||||
|
||||
for _, extractDir := range extractDirectories {
|
||||
found := false
|
||||
for _, pluginInstall := range d.Plugins {
|
||||
for _, extractedFolder := range pluginInstall.ExtractedVersions {
|
||||
if extractDir.Name() == extractedFolder {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
if err := os.RemoveAll(path.Join(pluginsExtractsFolder, extractDir.Name())); err != nil {
|
||||
return fmt.Errorf("failed to remove extract directory: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const pluginsExtractsFolder = pluginsFolder + "/extracts"
|
||||
|
||||
func init() {
|
||||
_ = os.MkdirAll(pluginsExtractsFolder, 0755)
|
||||
}
|
||||
|
||||
func extractPlugin(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file for extraction: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var reader io.Reader = file
|
||||
// TODO: there's probably a better way of doing this without relying on the file extension
|
||||
if strings.HasSuffix(filePath, ".gz") {
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
}
|
||||
|
||||
destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String())
|
||||
if err := os.MkdirAll(destinationFolder, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create extracts folder: %v", err)
|
||||
}
|
||||
|
||||
if err := extractTarball(reader, destinationFolder); err != nil {
|
||||
if err := os.RemoveAll(destinationFolder); err != nil {
|
||||
return "", fmt.Errorf("failed to remove failed extraction folder: %v", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to extract tarball: %v", err)
|
||||
}
|
||||
|
||||
return destinationFolder, nil
|
||||
}
|
||||
|
||||
func extractTarball(reader io.Reader, destinationFolder string) error {
|
||||
tarReader := tar.NewReader(reader)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
// Prevent path traversal attacks
|
||||
targetPath := filepath.Join(destinationFolder, header.Name)
|
||||
if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("tar file contains illegal path: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, tarReader); err != nil {
|
||||
return fmt.Errorf("failed to extract file: %v", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported tar entry type: %v", header.Typeflag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type PluginInstall struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Current active version of the plugin
|
||||
Version string `json:"version"`
|
||||
|
||||
// Map of a plugin version to the extracted directory
|
||||
ExtractedVersions map[string]string `json:"extracted_versions"`
|
||||
|
||||
manifest *PluginManifest
|
||||
runningVersion string
|
||||
processManager *ProcessManager
|
||||
rpcServer *PluginRpcServer
|
||||
}
|
||||
|
||||
func (p *PluginInstall) GetManifest() (*PluginManifest, error) {
|
||||
if p.manifest != nil {
|
||||
return p.manifest, nil
|
||||
}
|
||||
|
||||
manifest, err := readManifest(p.GetExtractedFolder())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.manifest = manifest
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func (p *PluginInstall) GetExtractedFolder() string {
|
||||
return p.ExtractedVersions[p.Version]
|
||||
}
|
||||
|
||||
func (p *PluginInstall) GetStatus() (*PluginStatus, error) {
|
||||
manifest, err := p.GetManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get plugin manifest: %v", err)
|
||||
}
|
||||
|
||||
status := PluginStatus{
|
||||
PluginManifest: *manifest,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
|
||||
// If the rpc server is connected and the plugin is reporting status, use that
|
||||
if p.rpcServer != nil &&
|
||||
p.rpcServer.status.Status != "disconnected" &&
|
||||
p.rpcServer.status.Status != "unknown" {
|
||||
status.Status = p.rpcServer.status.Status
|
||||
status.Message = p.rpcServer.status.Message
|
||||
|
||||
if status.Status == "error" {
|
||||
status.Message = p.rpcServer.status.Message
|
||||
}
|
||||
} else {
|
||||
status.Status = "stopped"
|
||||
if p.processManager != nil {
|
||||
status.Status = "running"
|
||||
if p.processManager.LastError != nil {
|
||||
status.Status = "error"
|
||||
status.Message = p.processManager.LastError.Error()
|
||||
}
|
||||
}
|
||||
log.Printf("Status from process manager: %v", status.Status)
|
||||
}
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (p *PluginInstall) ReconcileSubprocess() error {
|
||||
manifest, err := p.GetManifest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get plugin manifest: %v", err)
|
||||
}
|
||||
|
||||
versionRunning := p.runningVersion
|
||||
|
||||
versionShouldBeRunning := p.Version
|
||||
if !p.Enabled {
|
||||
versionShouldBeRunning = ""
|
||||
}
|
||||
|
||||
log.Printf("Reconciling plugin %s running %v, should be running %v", manifest.Name, versionRunning, versionShouldBeRunning)
|
||||
|
||||
if versionRunning == versionShouldBeRunning {
|
||||
log.Printf("Plugin %s is already running version %s", manifest.Name, versionRunning)
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.processManager != nil {
|
||||
log.Printf("Stopping plugin %s running version %s", manifest.Name, versionRunning)
|
||||
p.processManager.Disable()
|
||||
p.processManager = nil
|
||||
p.runningVersion = ""
|
||||
err = p.rpcServer.Stop()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop rpc server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if versionShouldBeRunning == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name)
|
||||
err = os.MkdirAll(workingDir, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create working directory: %v", err)
|
||||
}
|
||||
|
||||
p.rpcServer = NewPluginRpcServer(p, workingDir)
|
||||
err = p.rpcServer.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start rpc server: %v", err)
|
||||
}
|
||||
|
||||
p.processManager = NewProcessManager(func() *exec.Cmd {
|
||||
cmd := exec.Command(manifest.BinaryPath)
|
||||
cmd.Dir = p.GetExtractedFolder()
|
||||
cmd.Env = append(cmd.Env,
|
||||
"JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(),
|
||||
"JETKVM_PLUGIN_WORKING_DIR="+workingDir,
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
// Ensure that the process is killed when the parent dies
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
Pdeathsig: syscall.SIGKILL,
|
||||
}
|
||||
return cmd
|
||||
})
|
||||
p.processManager.StartMonitor()
|
||||
p.processManager.Enable()
|
||||
p.runningVersion = p.Version
|
||||
|
||||
// Clear out manifest so the new version gets pulled next time
|
||||
p.manifest = nil
|
||||
|
||||
log.Printf("Started plugin %s version %s", manifest.Name, p.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginInstall) Shutdown() {
|
||||
if p.processManager != nil {
|
||||
p.processManager.Disable()
|
||||
p.processManager = nil
|
||||
p.runningVersion = ""
|
||||
}
|
||||
|
||||
if p.rpcServer != nil {
|
||||
p.rpcServer.Stop()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,257 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"kvm/internal/storage"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const pluginsFolder = "/userdata/jetkvm/plugins"
|
||||
const pluginsUploadFolder = pluginsFolder + "/uploads"
|
||||
|
||||
func init() {
|
||||
_ = os.MkdirAll(pluginsUploadFolder, 0755)
|
||||
|
||||
if err := pluginDatabase.Load(); err != nil {
|
||||
fmt.Printf("failed to load plugin database: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Starts all plugins that need to be started
|
||||
func ReconcilePlugins() {
|
||||
for _, install := range pluginDatabase.Plugins {
|
||||
err := install.ReconcileSubprocess()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to reconcile subprocess for plugin: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GracefullyShutdownPlugins() {
|
||||
for _, install := range pluginDatabase.Plugins {
|
||||
install.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUpload, error) {
|
||||
sanitizedFilename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
|
||||
uploadPath := filePath + ".incomplete"
|
||||
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
return nil, fmt.Errorf("file already exists: %s", sanitizedFilename)
|
||||
}
|
||||
|
||||
var alreadyUploadedBytes int64 = 0
|
||||
if stat, err := os.Stat(uploadPath); err == nil {
|
||||
alreadyUploadedBytes = stat.Size()
|
||||
}
|
||||
|
||||
uploadId := "plugin_" + uuid.New().String()
|
||||
file, err := os.OpenFile(uploadPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file for upload: %v", err)
|
||||
}
|
||||
|
||||
storage.AddPendingUpload(uploadId, storage.PendingUpload{
|
||||
File: file,
|
||||
Size: size,
|
||||
AlreadyUploadedBytes: alreadyUploadedBytes,
|
||||
})
|
||||
|
||||
return &storage.StorageFileUpload{
|
||||
AlreadyUploadedBytes: alreadyUploadedBytes,
|
||||
DataChannel: uploadId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RpcPluginExtract(filename string) (*PluginManifest, error) {
|
||||
sanitizedFilename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
|
||||
extractFolder, err := extractPlugin(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete uploaded file: %v", err)
|
||||
}
|
||||
|
||||
manifest, err := readManifest(extractFolder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get existing PluginInstall
|
||||
install, ok := pluginDatabase.Plugins[manifest.Name]
|
||||
if !ok {
|
||||
install = &PluginInstall{
|
||||
Enabled: false,
|
||||
Version: manifest.Version,
|
||||
ExtractedVersions: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
_, ok = install.ExtractedVersions[manifest.Version]
|
||||
if ok {
|
||||
return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version)
|
||||
}
|
||||
|
||||
install.ExtractedVersions[manifest.Version] = extractFolder
|
||||
pluginDatabase.Plugins[manifest.Name] = install
|
||||
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func RpcPluginInstall(name string, version string) error {
|
||||
pluginInstall, ok := pluginDatabase.Plugins[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
if pluginInstall.Version == version && pluginInstall.Enabled {
|
||||
fmt.Printf("Plugin %s is already installed with version %s\n", name, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, ok = pluginInstall.ExtractedVersions[version]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin version not found: %s", version)
|
||||
}
|
||||
|
||||
pluginInstall.Version = version
|
||||
pluginInstall.Enabled = true
|
||||
pluginDatabase.Plugins[name] = pluginInstall
|
||||
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
|
||||
err := pluginInstall.ReconcileSubprocess()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start plugin %s: %v", name, err)
|
||||
}
|
||||
|
||||
// TODO: Determine if the old extract should be removed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RpcPluginList() ([]PluginStatus, error) {
|
||||
plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins))
|
||||
for pluginName, plugin := range pluginDatabase.Plugins {
|
||||
status, err := plugin.GetStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get plugin status for %s: %v", pluginName, err)
|
||||
}
|
||||
plugins = append(plugins, *status)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
func RpcPluginUpdateConfig(name string, enabled bool) (*PluginStatus, error) {
|
||||
pluginInstall, ok := pluginDatabase.Plugins[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
pluginInstall.Enabled = enabled
|
||||
pluginDatabase.Plugins[name] = pluginInstall
|
||||
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
|
||||
err := pluginInstall.ReconcileSubprocess()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop plugin %s: %v", name, err)
|
||||
}
|
||||
|
||||
status, err := pluginInstall.GetStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get plugin status for %s: %v", name, err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func RpcPluginUninstall(name string) error {
|
||||
pluginInstall, ok := pluginDatabase.Plugins[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
pluginInstall.Enabled = false
|
||||
|
||||
err := pluginInstall.ReconcileSubprocess()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop plugin %s: %v", name, err)
|
||||
}
|
||||
|
||||
delete(pluginDatabase.Plugins, name)
|
||||
if err := pluginDatabase.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save plugin database: %v", err)
|
||||
}
|
||||
|
||||
err = pluginDatabase.CleanupExtractDirectories()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cleanup extract directories: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readManifest(extractFolder string) (*PluginManifest, error) {
|
||||
manifestPath := path.Join(extractFolder, "manifest.json")
|
||||
manifestFile, err := os.Open(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest file: %v", err)
|
||||
}
|
||||
defer manifestFile.Close()
|
||||
|
||||
manifest := PluginManifest{}
|
||||
if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest file: %v", err)
|
||||
}
|
||||
|
||||
if err := validateManifest(&manifest); err != nil {
|
||||
return nil, fmt.Errorf("invalid manifest file: %v", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func validateManifest(manifest *PluginManifest) error {
|
||||
if manifest.ManifestVersion != "1" {
|
||||
return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion)
|
||||
}
|
||||
|
||||
if manifest.Name == "" {
|
||||
return fmt.Errorf("missing plugin name")
|
||||
}
|
||||
|
||||
if manifest.Version == "" {
|
||||
return fmt.Errorf("missing plugin version")
|
||||
}
|
||||
|
||||
if manifest.Homepage == "" {
|
||||
return fmt.Errorf("missing plugin homepage")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: this can probably be defaulted to this, but overwritten on a per-plugin basis
|
||||
const (
|
||||
gracefulShutdownDelay = 30 * time.Second
|
||||
maxRestartBackoff = 30 * time.Second
|
||||
)
|
||||
|
||||
type ProcessManager struct {
|
||||
cmdGen func() *exec.Cmd
|
||||
cmd *exec.Cmd
|
||||
enabled bool
|
||||
backoff time.Duration
|
||||
shutdown chan struct{}
|
||||
restartCh chan struct{}
|
||||
LastError error
|
||||
}
|
||||
|
||||
func NewProcessManager(commandGenerator func() *exec.Cmd) *ProcessManager {
|
||||
return &ProcessManager{
|
||||
cmdGen: commandGenerator,
|
||||
enabled: true,
|
||||
backoff: 250 * time.Millisecond,
|
||||
shutdown: make(chan struct{}),
|
||||
restartCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) StartMonitor() {
|
||||
go pm.monitor()
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) monitor() {
|
||||
for {
|
||||
select {
|
||||
case <-pm.shutdown:
|
||||
pm.terminate()
|
||||
return
|
||||
case <-pm.restartCh:
|
||||
if pm.enabled {
|
||||
go pm.runProcess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) runProcess() {
|
||||
pm.LastError = nil
|
||||
pm.cmd = pm.cmdGen()
|
||||
log.Printf("Starting process: %v", pm.cmd)
|
||||
err := pm.cmd.Start()
|
||||
if err != nil {
|
||||
log.Printf("Failed to start process: %v", err)
|
||||
pm.LastError = fmt.Errorf("failed to start process: %w", err)
|
||||
pm.scheduleRestart()
|
||||
return
|
||||
}
|
||||
|
||||
err = pm.cmd.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Process exited: %v", err)
|
||||
pm.LastError = fmt.Errorf("process exited with error: %w", err)
|
||||
pm.scheduleRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) scheduleRestart() {
|
||||
if pm.enabled {
|
||||
log.Printf("Restarting process in %v...", pm.backoff)
|
||||
time.Sleep(pm.backoff)
|
||||
pm.backoff *= 2 // Exponential backoff
|
||||
if pm.backoff > maxRestartBackoff {
|
||||
pm.backoff = maxRestartBackoff
|
||||
}
|
||||
pm.restartCh <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) terminate() {
|
||||
if pm.cmd.Process != nil {
|
||||
log.Printf("Sending SIGTERM...")
|
||||
pm.cmd.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case <-time.After(gracefulShutdownDelay):
|
||||
log.Printf("Forcing process termination...")
|
||||
pm.cmd.Process.Kill()
|
||||
case <-pm.waitForExit():
|
||||
log.Printf("Process exited gracefully.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) waitForExit() <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
pm.cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Enable() {
|
||||
pm.enabled = true
|
||||
pm.restartCh <- struct{}{}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Disable() {
|
||||
pm.enabled = false
|
||||
close(pm.shutdown)
|
||||
pm.cmd.Wait()
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kvm/internal/jsonrpc"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PluginRpcStatus struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""}
|
||||
PluginRpcStatusUnknown = PluginRpcStatus{"unknown", ""}
|
||||
PluginRpcStatusLoading = PluginRpcStatus{"loading", ""}
|
||||
PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""}
|
||||
PluginRpcStatusRunning = PluginRpcStatus{"running", ""}
|
||||
PluginRpcStatusError = PluginRpcStatus{"error", ""}
|
||||
)
|
||||
|
||||
type PluginRpcSupportedMethods struct {
|
||||
SupportedRpcMethods []string `json:"supported_rpc_methods"`
|
||||
}
|
||||
|
||||
type PluginRpcServer struct {
|
||||
install *PluginInstall
|
||||
workingDir string
|
||||
|
||||
listener net.Listener
|
||||
status PluginRpcStatus
|
||||
}
|
||||
|
||||
func NewPluginRpcServer(install *PluginInstall, workingDir string) *PluginRpcServer {
|
||||
return &PluginRpcServer{
|
||||
install: install,
|
||||
workingDir: workingDir,
|
||||
status: PluginRpcStatusDisconnected,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) Start() error {
|
||||
socketPath := s.SocketPath()
|
||||
_ = os.Remove(socketPath)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on socket: %v", err)
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
s.status = PluginRpcStatusDisconnected
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
// If the error indicates the listener is closed, break out
|
||||
if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
|
||||
log.Println("Listener closed, exiting accept loop.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Failed to accept connection: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Accepted plugin rpc connection from %v", conn.RemoteAddr())
|
||||
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) Stop() error {
|
||||
if s.listener != nil {
|
||||
s.status = PluginRpcStatusDisconnected
|
||||
return s.listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) Status() PluginRpcStatus {
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) SocketPath() string {
|
||||
return path.Join(s.workingDir, "plugin.sock")
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) handleConnection(conn net.Conn) {
|
||||
rpcserver := jsonrpc.NewJSONRPCRouter(conn, map[string]*jsonrpc.RPCHandler{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go s.handleRpcStatus(ctx, rpcserver)
|
||||
|
||||
// Read from the conn and write into rpcserver.HandleMessage
|
||||
buf := make([]byte, 65*1024)
|
||||
for {
|
||||
// TODO: if read 65k bytes, then likey there is more data to read... figure out how to handle this
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
s.status = PluginRpcStatusDisconnected
|
||||
} else {
|
||||
log.Printf("Failed to read message: %v", err)
|
||||
s.status = PluginRpcStatusError
|
||||
s.status.Message = fmt.Errorf("failed to read message: %v", err).Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
err = rpcserver.HandleMessage(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("Failed to handle message: %v", err)
|
||||
s.status = PluginRpcStatusError
|
||||
s.status.Message = fmt.Errorf("failed to handle message: %v", err).Error()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCRouter) {
|
||||
s.status = PluginRpcStatusUnknown
|
||||
|
||||
log.Printf("Plugin rpc server started. Getting supported methods...")
|
||||
var supportedMethodsResponse PluginRpcSupportedMethods
|
||||
err := rpcserver.Request("getPluginSupportedMethods", nil, &supportedMethodsResponse)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get supported methods: %v", err)
|
||||
s.status = PluginRpcStatusError
|
||||
s.status.Message = fmt.Errorf("error getting supported methods: %v", err.Message).Error()
|
||||
}
|
||||
|
||||
log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.SupportedRpcMethods)
|
||||
|
||||
if !slices.Contains(supportedMethodsResponse.SupportedRpcMethods, "getPluginStatus") {
|
||||
log.Printf("Plugin does not support getPluginStatus method")
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
var statusResponse PluginRpcStatus
|
||||
err := rpcserver.Request("getPluginStatus", nil, &statusResponse)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get status: %v", err)
|
||||
if err, ok := err.Data.(error); ok && errors.Is(err, net.ErrClosed) {
|
||||
s.status = PluginRpcStatusDisconnected
|
||||
break
|
||||
}
|
||||
|
||||
s.status = PluginRpcStatusError
|
||||
s.status.Message = fmt.Errorf("error getting status: %v", err).Error()
|
||||
continue
|
||||
}
|
||||
|
||||
s.status = statusResponse
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package plugin
|
||||
|
||||
type PluginManifest struct {
|
||||
ManifestVersion string `json:"manifest_version"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Homepage string `json:"homepage"`
|
||||
BinaryPath string `json:"bin"`
|
||||
SystemMinVersion string `json:"system_min_version,omitempty"`
|
||||
}
|
||||
|
||||
type PluginStatus struct {
|
||||
PluginManifest
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package storage
|
||||
|
||||
type StorageFileUpload struct {
|
||||
AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"`
|
||||
DataChannel string `json:"dataChannel"`
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PendingUpload struct {
|
||||
File *os.File
|
||||
Size int64
|
||||
AlreadyUploadedBytes int64
|
||||
}
|
||||
|
||||
var pendingUploads = make(map[string]PendingUpload)
|
||||
var pendingUploadsMutex sync.Mutex
|
||||
|
||||
func GetPendingUpload(uploadId string) (PendingUpload, bool) {
|
||||
pendingUploadsMutex.Lock()
|
||||
defer pendingUploadsMutex.Unlock()
|
||||
upload, ok := pendingUploads[uploadId]
|
||||
return upload, ok
|
||||
}
|
||||
|
||||
func AddPendingUpload(uploadId string, upload PendingUpload) {
|
||||
pendingUploadsMutex.Lock()
|
||||
defer pendingUploadsMutex.Unlock()
|
||||
pendingUploads[uploadId] = upload
|
||||
}
|
||||
|
||||
func DeletePendingUpload(uploadId string) {
|
||||
pendingUploadsMutex.Lock()
|
||||
defer pendingUploadsMutex.Unlock()
|
||||
delete(pendingUploads, uploadId)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SanitizeFilename(filename string) (string, error) {
|
||||
cleanPath := filepath.Clean(filename)
|
||||
if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
sanitized := filepath.Base(cleanPath)
|
||||
if sanitized == "." || sanitized == string(filepath.Separator) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
return sanitized, nil
|
||||
}
|
213
jsonrpc.go
213
jsonrpc.go
|
@ -5,50 +5,45 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kvm/internal/jsonrpc"
|
||||
"kvm/internal/plugin"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
type DataChannelWriter struct {
|
||||
dataChannel *webrtc.DataChannel
|
||||
}
|
||||
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
|
||||
type JSONRPCEvent struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
log.Println("Error marshalling JSONRPC response:", err)
|
||||
return
|
||||
func NewDataChannelWriter(dataChannel *webrtc.DataChannel) *DataChannelWriter {
|
||||
return &DataChannelWriter{
|
||||
dataChannel: dataChannel,
|
||||
}
|
||||
err = session.RPCChannel.SendText(string(responseBytes))
|
||||
}
|
||||
|
||||
func (w *DataChannelWriter) Write(data []byte) (int, error) {
|
||||
err := w.dataChannel.SendText(string(data))
|
||||
if err != nil {
|
||||
log.Println("Error sending JSONRPC response:", err)
|
||||
return
|
||||
return 0, err
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func NewDataChannelJsonRpcRouter(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCRouter {
|
||||
return jsonrpc.NewJSONRPCRouter(
|
||||
NewDataChannelWriter(dataChannel),
|
||||
rpcHandlers,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: embed this into the session's rpc server
|
||||
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||
request := JSONRPCEvent{
|
||||
request := jsonrpc.JSONRPCEvent{
|
||||
JSONRPC: "2.0",
|
||||
Method: event,
|
||||
Params: params,
|
||||
|
@ -69,60 +64,6 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
|||
}
|
||||
}
|
||||
|
||||
func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||
var request JSONRPCRequest
|
||||
err := json.Unmarshal(message.Data, &request)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
"code": -32700,
|
||||
"message": "Parse error",
|
||||
},
|
||||
ID: 0,
|
||||
}
|
||||
writeJSONRPCResponse(errorResponse, session)
|
||||
return
|
||||
}
|
||||
|
||||
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
|
||||
handler, ok := rpcHandlers[request.Method]
|
||||
if !ok {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
"code": -32601,
|
||||
"message": "Method not found",
|
||||
},
|
||||
ID: request.ID,
|
||||
}
|
||||
writeJSONRPCResponse(errorResponse, session)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := callRPCHandler(handler, request.Params)
|
||||
if err != nil {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
"code": -32603,
|
||||
"message": "Internal error",
|
||||
"data": err.Error(),
|
||||
},
|
||||
ID: request.ID,
|
||||
}
|
||||
writeJSONRPCResponse(errorResponse, session)
|
||||
return
|
||||
}
|
||||
|
||||
response := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Result: result,
|
||||
ID: request.ID,
|
||||
}
|
||||
writeJSONRPCResponse(response, session)
|
||||
}
|
||||
|
||||
func rpcPing() (string, error) {
|
||||
return "pong", nil
|
||||
}
|
||||
|
@ -321,108 +262,6 @@ func rpcSetSSHKeyState(sshKey string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
||||
handlerValue := reflect.ValueOf(handler.Func)
|
||||
handlerType := handlerValue.Type()
|
||||
|
||||
if handlerType.Kind() != reflect.Func {
|
||||
return nil, errors.New("handler is not a function")
|
||||
}
|
||||
|
||||
numParams := handlerType.NumIn()
|
||||
args := make([]reflect.Value, numParams)
|
||||
// Get the parameter names from the RPCHandler
|
||||
paramNames := handler.Params
|
||||
|
||||
if len(paramNames) != numParams {
|
||||
return nil, errors.New("mismatch between handler parameters and defined parameter names")
|
||||
}
|
||||
|
||||
for i := 0; i < numParams; i++ {
|
||||
paramType := handlerType.In(i)
|
||||
paramName := paramNames[i]
|
||||
paramValue, ok := params[paramName]
|
||||
if !ok {
|
||||
return nil, errors.New("missing parameter: " + paramName)
|
||||
}
|
||||
|
||||
convertedValue := reflect.ValueOf(paramValue)
|
||||
if !convertedValue.Type().ConvertibleTo(paramType) {
|
||||
if paramType.Kind() == reflect.Slice && (convertedValue.Kind() == reflect.Slice || convertedValue.Kind() == reflect.Array) {
|
||||
newSlice := reflect.MakeSlice(paramType, convertedValue.Len(), convertedValue.Len())
|
||||
for j := 0; j < convertedValue.Len(); j++ {
|
||||
elemValue := convertedValue.Index(j)
|
||||
if elemValue.Kind() == reflect.Interface {
|
||||
elemValue = elemValue.Elem()
|
||||
}
|
||||
if !elemValue.Type().ConvertibleTo(paramType.Elem()) {
|
||||
// Handle float64 to uint8 conversion
|
||||
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
|
||||
intValue := int(elemValue.Float())
|
||||
if intValue < 0 || intValue > 255 {
|
||||
return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
|
||||
}
|
||||
newSlice.Index(j).SetUint(uint64(intValue))
|
||||
} else {
|
||||
fromType := elemValue.Type()
|
||||
toType := paramType.Elem()
|
||||
return nil, fmt.Errorf("invalid element type in slice for parameter %s: from %v to %v", paramName, fromType, toType)
|
||||
}
|
||||
} else {
|
||||
newSlice.Index(j).Set(elemValue.Convert(paramType.Elem()))
|
||||
}
|
||||
}
|
||||
args[i] = newSlice
|
||||
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
|
||||
jsonData, err := json.Marshal(convertedValue.Interface())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
|
||||
}
|
||||
|
||||
newStruct := reflect.New(paramType).Interface()
|
||||
if err := json.Unmarshal(jsonData, newStruct); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
|
||||
}
|
||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
|
||||
}
|
||||
} else {
|
||||
args[i] = convertedValue.Convert(paramType)
|
||||
}
|
||||
}
|
||||
|
||||
results := handlerValue.Call(args)
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(results) == 1 {
|
||||
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[0].IsNil() {
|
||||
return nil, results[0].Interface().(error)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
|
||||
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[1].IsNil() {
|
||||
return nil, results[1].Interface().(error)
|
||||
}
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
|
||||
return nil, errors.New("unexpected return values from handler")
|
||||
}
|
||||
|
||||
type RPCHandler struct {
|
||||
Func interface{}
|
||||
Params []string
|
||||
}
|
||||
|
||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
|
||||
var cdrom bool
|
||||
|
@ -514,7 +353,7 @@ func rpcResetConfig() error {
|
|||
}
|
||||
|
||||
// TODO: replace this crap with code generator
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
var rpcHandlers = map[string]*jsonrpc.RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||
|
@ -560,4 +399,10 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
|
||||
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
|
||||
"pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
|
||||
"pluginList": {Func: plugin.RpcPluginList},
|
||||
"pluginUpdateConfig": {Func: plugin.RpcPluginUpdateConfig, Params: []string{"name", "enabled"}},
|
||||
"pluginUninstall": {Func: plugin.RpcPluginUninstall, Params: []string{"name"}},
|
||||
}
|
||||
|
|
6
main.go
6
main.go
|
@ -2,6 +2,7 @@ package kvm
|
|||
|
||||
import (
|
||||
"context"
|
||||
"kvm/internal/plugin"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
@ -66,15 +67,20 @@ func Main() {
|
|||
}()
|
||||
//go RunFuseServer()
|
||||
go RunWebServer()
|
||||
go plugin.ReconcilePlugins()
|
||||
|
||||
// If the cloud token isn't set, the client won't be started by default.
|
||||
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
|
||||
if config.CloudToken != "" {
|
||||
go RunWebsocketClient()
|
||||
}
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
log.Println("JetKVM Shutting Down")
|
||||
|
||||
plugin.GracefullyShutdownPlugins()
|
||||
//if fuseServer != nil {
|
||||
// err := setMassStorageImage(" ")
|
||||
// if err != nil {
|
||||
|
|
|
@ -1527,7 +1527,7 @@ function PreUploadedImageItem({
|
|||
);
|
||||
}
|
||||
|
||||
function ViewHeader({ title, description }: { title: string; description: string }) {
|
||||
export function ViewHeader({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
import { PluginStatus, usePluginStore } from "@/hooks/stores";
|
||||
import Modal from "@components/Modal";
|
||||
import AutoHeight from "@components/AutoHeight";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import { ViewHeader } from "./MountMediaDialog";
|
||||
import { Button } from "./Button";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PluginStatusIcon } from "./PluginStatusIcon";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
export default function PluginConfigureModal({
|
||||
plugin,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
plugin: PluginStatus | null;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={!!plugin && open} onClose={() => setOpen(false)}>
|
||||
<Dialog plugin={plugin} setOpen={setOpen} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (open: boolean) => void }) {
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {setIsPluginUploadModalOpen} = usePluginStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [plugin])
|
||||
|
||||
const updatePlugin = useCallback((enabled: boolean) => {
|
||||
if (!plugin) return;
|
||||
if (!enabled) {
|
||||
if (!window.confirm("Are you sure you want to disable this plugin?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
send("pluginUpdateConfig", { name: plugin.name, enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
setLoading(false);
|
||||
setError(resp.error.message);
|
||||
return
|
||||
}
|
||||
setOpen(false);
|
||||
});
|
||||
}, [send, plugin, setOpen])
|
||||
|
||||
const uninstallPlugin = useCallback(() => {
|
||||
if (!plugin) return;
|
||||
if (!window.confirm("Are you sure you want to uninstall this plugin? This will not delete any data.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
send("pluginUninstall", { name: plugin.name }, resp => {
|
||||
if ("error" in resp) {
|
||||
setLoading(false);
|
||||
setError(resp.error.message);
|
||||
return
|
||||
}
|
||||
setOpen(false);
|
||||
});
|
||||
}, [send, plugin, setOpen])
|
||||
|
||||
const uploadPlugin = useCallback(() => {
|
||||
setOpen(false);
|
||||
setIsPluginUploadModalOpen(true);
|
||||
}, [setIsPluginUploadModalOpen, setOpen])
|
||||
|
||||
return (
|
||||
<AutoHeight>
|
||||
<div className="mx-auto max-w-4xl px-4 transition-all duration-300 ease-in-out">
|
||||
<GridCard cardClassName="relative w-full text-left pointer-events-auto">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="flex justify-between w-full">
|
||||
<div>
|
||||
<img
|
||||
src={LogoBlueIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:hidden block"
|
||||
/>
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:block hidden dark:!mt-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{plugin && <>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 inline-block">
|
||||
{plugin.status}
|
||||
</p>
|
||||
<PluginStatusIcon plugin={plugin} />
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<ViewHeader title="Plugin Configuration" description={`Configure the ${plugin?.name} plugin`} />
|
||||
<div>
|
||||
{/* Enable/Disable toggle */}
|
||||
<Button
|
||||
size="MD"
|
||||
theme={plugin?.enabled ? "danger" : "light"}
|
||||
text={plugin?.enabled ? "Disable Plugin" : "Enable Plugin"}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
updatePlugin(!plugin?.enabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-4 text-sm text-black dark:text-white">
|
||||
<span className="font-semibold">
|
||||
Name
|
||||
</span>
|
||||
<span>{plugin?.name}</span>
|
||||
|
||||
<span className="font-semibold">
|
||||
Active Version
|
||||
</span>
|
||||
<span>{plugin?.version}</span>
|
||||
|
||||
<span className="font-semibold">
|
||||
Description
|
||||
</span>
|
||||
<span>{plugin?.description}</span>
|
||||
|
||||
<span className="font-semibold">
|
||||
Homepage
|
||||
</span>
|
||||
<a href={plugin?.homepage} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400">
|
||||
{plugin?.homepage}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
>
|
||||
{error && <p className="text-red-500 dark:text-red-400">{error}</p>}
|
||||
{plugin?.message && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Plugin message:
|
||||
</p>
|
||||
<Card className={cx(
|
||||
"text-gray-500 dark:text-gray-400 p-4 border",
|
||||
plugin.status === "error" && "border-red-200 bg-red-50 text-red-800 dark:text-red-400",
|
||||
)}>
|
||||
{plugin.message}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-10">
|
||||
Plugin configuration coming soon
|
||||
</p>
|
||||
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<div
|
||||
className="flex items-end w-full opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center w-full space-x-2">
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text="Upload New Version"
|
||||
disabled={loading}
|
||||
onClick={uploadPlugin}
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="blank"
|
||||
text="Uninstall Plugin"
|
||||
disabled={loading}
|
||||
onClick={uninstallPlugin}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end w-full space-x-2">
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Back"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</div >
|
||||
</AutoHeight >
|
||||
)
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "@components/Button";
|
||||
import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import UploadPluginModal from "@components/UploadPluginDialog";
|
||||
import PluginConfigureModal from "@components/PluginConfigureDialog";
|
||||
import { PluginStatusIcon } from "./PluginStatusIcon";
|
||||
|
||||
export default function PluginList() {
|
||||
const [send] = useJsonRpc();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
isPluginUploadModalOpen,
|
||||
setIsPluginUploadModalOpen,
|
||||
setPluginUploadModalView,
|
||||
plugins,
|
||||
setPlugins,
|
||||
pluginConfigureModalOpen,
|
||||
setPluginConfigureModalOpen,
|
||||
configuringPlugin,
|
||||
setConfiguringPlugin,
|
||||
} = usePluginStore();
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
|
||||
const updatePlugins = useCallback(() => {
|
||||
setError(null);
|
||||
send("pluginList", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
setError(resp.error.message);
|
||||
return
|
||||
}
|
||||
console.log('pluginList', resp.result);
|
||||
setPlugins(resp.result as PluginStatus[]);
|
||||
});
|
||||
}, [send, setPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
// Only update plugins when the sidebar view is the settings view
|
||||
if (sidebarView !== "system" && !pluginConfigureModalOpen) return;
|
||||
updatePlugins();
|
||||
|
||||
const updateInterval = setInterval(() => {
|
||||
updatePlugins();
|
||||
}, 10_000);
|
||||
return () => clearInterval(updateInterval);
|
||||
}, [updatePlugins, sidebarView, pluginConfigureModalOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-auto max-h-40 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<ul role="list" className="divide-y divide-gray-200 dark:divide-gray-700 w-full">
|
||||
{error && <li className="text-red-500 dark:text-red-400">{error}</li>}
|
||||
{plugins.length === 0 && <li className="text-sm text-center text-gray-500 dark:text-gray-400 py-5">No plugins installed</li>}
|
||||
{plugins.map(plugin => (
|
||||
<li key={plugin.name} className="flex items-center justify-between pa-2 py-2 gap-x-2">
|
||||
<PluginStatusIcon plugin={plugin} />
|
||||
<div className="overflow-hidden flex grow flex-col">
|
||||
<p className="text-base font-semibold text-black dark:text-white">{plugin.name}</p>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300 line-clamp-1">
|
||||
<a href={plugin.homepage} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400">{plugin.homepage}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center w-20">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
onClick={() => {
|
||||
setConfiguringPlugin(plugin.name);
|
||||
setPluginConfigureModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PluginConfigureModal
|
||||
open={pluginConfigureModalOpen}
|
||||
setOpen={(open) => {
|
||||
setPluginConfigureModalOpen(open);
|
||||
if (!open) {
|
||||
updatePlugins();
|
||||
}
|
||||
}}
|
||||
plugin={plugins.find(p => p.name == configuringPlugin) ?? null}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Upload Plugin"
|
||||
onClick={() => {
|
||||
setPluginUploadModalView("upload");
|
||||
setIsPluginUploadModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
<UploadPluginModal
|
||||
open={isPluginUploadModalOpen}
|
||||
setOpen={(open) => {
|
||||
setIsPluginUploadModalOpen(open);
|
||||
if (!open) {
|
||||
updatePlugins();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { cx } from "@/cva.config";
|
||||
import { PluginStatus } from "@/hooks/stores";
|
||||
|
||||
export function PluginStatusIcon({ plugin }: { plugin: PluginStatus; }) {
|
||||
let classNames = "bg-slate-500 border-slate-600";
|
||||
if (plugin.enabled && plugin.status === "running") {
|
||||
classNames = "bg-green-500 border-green-600";
|
||||
} else if (plugin.enabled && plugin.status === "pending-configuration") {
|
||||
classNames = "bg-yellow-500 border-yellow-600";
|
||||
} else if (plugin.enabled && plugin.status === "error") {
|
||||
classNames = "bg-red-500 border-red-600";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center px-2" title={plugin.status}>
|
||||
<div className={cx("h-2 w-2 rounded-full border transition", classNames)} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,679 @@
|
|||
import Card, { GridCard } from "@/components/Card";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import {
|
||||
PluginManifest,
|
||||
usePluginStore,
|
||||
useRTCStore,
|
||||
} from "../hooks/stores";
|
||||
import { cx } from "../cva.config";
|
||||
import {
|
||||
LuCheck,
|
||||
LuUpload,
|
||||
} from "react-icons/lu";
|
||||
import { formatters } from "@/utils";
|
||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import AutoHeight from "./AutoHeight";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import notifications from "../notifications";
|
||||
import { isOnDevice } from "../main";
|
||||
import { ViewHeader } from "./MountMediaDialog";
|
||||
|
||||
export default function UploadPluginModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const {
|
||||
pluginUploadModalView,
|
||||
setPluginUploadModalView,
|
||||
pluginUploadFilename,
|
||||
setPluginUploadFilename,
|
||||
pluginUploadManifest,
|
||||
setPluginUploadManifest,
|
||||
setConfiguringPlugin,
|
||||
setPluginConfigureModalOpen,
|
||||
} = usePluginStore();
|
||||
const [send] = useJsonRpc();
|
||||
const [extractError, setExtractError] = useState<string | null>(null);
|
||||
|
||||
function extractPlugin(filename: string) {
|
||||
send("pluginExtract", { filename }, resp => {
|
||||
if ("error" in resp) {
|
||||
setExtractError(resp.error.data || resp.error.message);
|
||||
return
|
||||
}
|
||||
|
||||
setPluginUploadManifest(resp.result as PluginManifest);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoHeight>
|
||||
<div
|
||||
className="mx-auto max-w-4xl px-4 transition-all duration-300 ease-in-out max-w-xl"
|
||||
>
|
||||
<GridCard cardClassName="relative w-full text-left pointer-events-auto">
|
||||
<div className="p-10">
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<img
|
||||
src={LogoBlueIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:hidden block"
|
||||
/>
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:block hidden dark:!mt-0"
|
||||
/>
|
||||
|
||||
{!extractError && pluginUploadModalView === "upload" && <UploadFileView
|
||||
onBack={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
onUploadCompleted={(filename) => {
|
||||
setPluginUploadFilename(filename)
|
||||
setPluginUploadModalView("install")
|
||||
extractPlugin(filename)
|
||||
}}
|
||||
/>}
|
||||
|
||||
{extractError && (
|
||||
<ErrorView
|
||||
errorMessage={extractError}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
setPluginUploadFilename(null)
|
||||
setExtractError(null)
|
||||
}}
|
||||
onRetry={() => {
|
||||
setExtractError(null)
|
||||
setPluginUploadFilename(null)
|
||||
setPluginUploadModalView("upload")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!extractError && pluginUploadModalView === "install" && <InstallPluginView
|
||||
filename={pluginUploadFilename!}
|
||||
manifest={pluginUploadManifest}
|
||||
onInstall={() => {
|
||||
setOpen(false)
|
||||
setPluginUploadFilename(null)
|
||||
if (pluginUploadManifest) {
|
||||
setConfiguringPlugin(pluginUploadManifest.name)
|
||||
setPluginConfigureModalOpen(true)
|
||||
}
|
||||
setPluginUploadManifest(null)
|
||||
setPluginUploadModalView("upload")
|
||||
}}
|
||||
onBack={() => {
|
||||
setPluginUploadModalView("upload")
|
||||
setPluginUploadFilename(null)
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
</AutoHeight>
|
||||
);
|
||||
}
|
||||
|
||||
// This is pretty much a copy-paste from the UploadFileView component in the MountMediaDialog just with the media terminology changed and the rpc method changed.
|
||||
// TODO: refactor to a shared component
|
||||
function UploadFileView({
|
||||
onBack,
|
||||
onUploadCompleted,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onUploadCompleted: (filename: string) => void;
|
||||
}) {
|
||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
|
||||
"idle",
|
||||
);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [uploadedFileSize, setUploadedFileSize] = useState<number | null>(null);
|
||||
const [uploadSpeed, setUploadSpeed] = useState<number | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ref = rtcDataChannelRef.current;
|
||||
return () => {
|
||||
if (ref) {
|
||||
ref.onopen = null;
|
||||
ref.onerror = null;
|
||||
ref.onmessage = null;
|
||||
ref.onclose = null;
|
||||
ref.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleWebRTCUpload(
|
||||
file: File,
|
||||
alreadyUploadedBytes: number,
|
||||
dataChannel: string,
|
||||
) {
|
||||
const rtcDataChannel = useRTCStore
|
||||
.getState()
|
||||
.peerConnection?.createDataChannel(dataChannel);
|
||||
|
||||
if (!rtcDataChannel) {
|
||||
console.error("Failed to create data channel for file upload");
|
||||
notifications.error("Failed to create data channel for file upload");
|
||||
setUploadState("idle");
|
||||
console.log("Upload state set to 'idle'");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
rtcDataChannelRef.current = rtcDataChannel;
|
||||
|
||||
const lowWaterMark = 256 * 1024;
|
||||
const highWaterMark = 1 * 1024 * 1024;
|
||||
rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark;
|
||||
|
||||
let lastUploadedBytes = alreadyUploadedBytes;
|
||||
let lastUpdateTime = Date.now();
|
||||
const speedHistory: number[] = [];
|
||||
|
||||
rtcDataChannel.onmessage = e => {
|
||||
try {
|
||||
const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as {
|
||||
AlreadyUploadedBytes: number;
|
||||
Size: number;
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const timeDiff = (now - lastUpdateTime) / 1000; // in seconds
|
||||
const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes;
|
||||
|
||||
if (timeDiff > 0) {
|
||||
const instantSpeed = bytesDiff / timeDiff; // bytes per second
|
||||
|
||||
// Add to speed history, keeping last 5 readings
|
||||
speedHistory.push(instantSpeed);
|
||||
if (speedHistory.length > 5) {
|
||||
speedHistory.shift();
|
||||
}
|
||||
|
||||
// Calculate average speed
|
||||
const averageSpeed =
|
||||
speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length;
|
||||
|
||||
setUploadSpeed(averageSpeed);
|
||||
setUploadProgress((AlreadyUploadedBytes / Size) * 100);
|
||||
}
|
||||
|
||||
lastUploadedBytes = AlreadyUploadedBytes;
|
||||
lastUpdateTime = now;
|
||||
} catch (e) {
|
||||
console.error("Error processing RTC Data channel message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
rtcDataChannel.onopen = () => {
|
||||
let pauseSending = false; // Pause sending when the buffered amount is high
|
||||
const chunkSize = 4 * 1024; // 4KB chunks
|
||||
|
||||
let offset = alreadyUploadedBytes;
|
||||
const sendNextChunk = () => {
|
||||
if (offset >= file.size) {
|
||||
rtcDataChannel.close();
|
||||
setUploadState("success");
|
||||
onUploadCompleted(file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pauseSending) return;
|
||||
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
chunk.arrayBuffer().then(buffer => {
|
||||
rtcDataChannel.send(buffer);
|
||||
|
||||
if (rtcDataChannel.bufferedAmount >= highWaterMark) {
|
||||
pauseSending = true;
|
||||
}
|
||||
|
||||
offset += buffer.byteLength;
|
||||
console.log(`Chunk sent: ${offset} / ${file.size} bytes`);
|
||||
sendNextChunk();
|
||||
});
|
||||
};
|
||||
|
||||
sendNextChunk();
|
||||
rtcDataChannel.onbufferedamountlow = () => {
|
||||
console.log("RTC Data channel buffered amount low");
|
||||
pauseSending = false; // Now the data channel is ready to send more data
|
||||
sendNextChunk();
|
||||
};
|
||||
};
|
||||
|
||||
rtcDataChannel.onerror = error => {
|
||||
console.error("RTC Data channel error:", error);
|
||||
notifications.error(`Upload failed: ${error}`);
|
||||
setUploadState("idle");
|
||||
console.log("Upload state set to 'idle'");
|
||||
};
|
||||
}
|
||||
|
||||
async function handleHttpUpload(
|
||||
file: File,
|
||||
alreadyUploadedBytes: number,
|
||||
dataChannel: string,
|
||||
) {
|
||||
const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", uploadUrl, true);
|
||||
|
||||
let lastUploadedBytes = alreadyUploadedBytes;
|
||||
let lastUpdateTime = Date.now();
|
||||
const speedHistory: number[] = [];
|
||||
|
||||
xhr.upload.onprogress = event => {
|
||||
if (event.lengthComputable) {
|
||||
const totalUploaded = alreadyUploadedBytes + event.loaded;
|
||||
const totalSize = file.size;
|
||||
|
||||
const now = Date.now();
|
||||
const timeDiff = (now - lastUpdateTime) / 1000; // in seconds
|
||||
const bytesDiff = totalUploaded - lastUploadedBytes;
|
||||
|
||||
if (timeDiff > 0) {
|
||||
const instantSpeed = bytesDiff / timeDiff; // bytes per second
|
||||
|
||||
// Add to speed history, keeping last 5 readings
|
||||
speedHistory.push(instantSpeed);
|
||||
if (speedHistory.length > 5) {
|
||||
speedHistory.shift();
|
||||
}
|
||||
|
||||
// Calculate average speed
|
||||
const averageSpeed =
|
||||
speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length;
|
||||
|
||||
setUploadSpeed(averageSpeed);
|
||||
setUploadProgress((totalUploaded / totalSize) * 100);
|
||||
}
|
||||
|
||||
lastUploadedBytes = totalUploaded;
|
||||
lastUpdateTime = now;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
setUploadState("success");
|
||||
onUploadCompleted(file.name);
|
||||
} else {
|
||||
console.error("Upload error:", xhr.statusText);
|
||||
setUploadError(xhr.statusText);
|
||||
setUploadState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
console.error("XHR error:", xhr.statusText);
|
||||
setUploadError(xhr.statusText);
|
||||
setUploadState("idle");
|
||||
};
|
||||
|
||||
// Prepare the data to send
|
||||
const blob = file.slice(alreadyUploadedBytes);
|
||||
|
||||
// Send the file data
|
||||
xhr.send(blob);
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Reset the upload error when a new file is selected
|
||||
setUploadError(null);
|
||||
|
||||
setFileError(null);
|
||||
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
|
||||
setUploadedFileName(file.name);
|
||||
setUploadedFileSize(file.size);
|
||||
setUploadState("uploading");
|
||||
console.log("Upload state set to 'uploading'");
|
||||
|
||||
send("pluginStartUpload", { filename: file.name, size: file.size }, resp => {
|
||||
console.log("pluginStartUpload response:", resp);
|
||||
if ("error" in resp) {
|
||||
console.error("Upload error:", resp.error.message);
|
||||
setUploadError(resp.error.data || resp.error.message);
|
||||
setUploadState("idle");
|
||||
console.log("Upload state set to 'idle'");
|
||||
return;
|
||||
}
|
||||
|
||||
const { alreadyUploadedBytes, dataChannel } = resp.result as {
|
||||
alreadyUploadedBytes: number;
|
||||
dataChannel: string;
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`,
|
||||
);
|
||||
|
||||
if (isOnDevice) {
|
||||
handleHttpUpload(file, alreadyUploadedBytes, dataChannel);
|
||||
} else {
|
||||
handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<ViewHeader
|
||||
title="Upload Plugin"
|
||||
description="Select a plugin archive TAR to upload to the JetKVM"
|
||||
/>
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (uploadState === "idle") {
|
||||
document.getElementById("file-upload")?.click();
|
||||
}
|
||||
}}
|
||||
className="block select-none"
|
||||
>
|
||||
<div className="group">
|
||||
<Card
|
||||
className={cx("transition-all duration-300", {
|
||||
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle",
|
||||
})}
|
||||
>
|
||||
<div className="h-[186px] w-full px-4">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
{uploadState === "idle" && (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
|
||||
Click to select a file
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
Supported formats: TAR, TAR.GZ
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === "uploading" && (
|
||||
<div className="w-full max-w-sm space-y-2 text-left">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-black leading-non dark:text-white">
|
||||
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
{formatters.bytes(uploadedFileSize || 0)}
|
||||
</p>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
|
||||
<span>Uploading...</span>
|
||||
<span>
|
||||
{uploadSpeed !== null
|
||||
? `${formatters.bytes(uploadSpeed)}/s`
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === "success" && (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
|
||||
Upload successful
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
{formatters.truncateMiddle(uploadedFileName, 40)} has been
|
||||
uploaded
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
// Can't put .tar.gz as browsers don't support 2 dots
|
||||
accept=".tar, .gz"
|
||||
/>
|
||||
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Display upload error if present */}
|
||||
{uploadError && (
|
||||
<div
|
||||
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
>
|
||||
Error: {uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-end w-full opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end w-full space-x-2">
|
||||
{uploadState === "uploading" ? (
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Cancel Upload"
|
||||
onClick={() => {
|
||||
onBack();
|
||||
setUploadState("idle");
|
||||
setUploadProgress(0);
|
||||
setUploadedFileName(null);
|
||||
setUploadedFileSize(null);
|
||||
setUploadSpeed(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Back"
|
||||
onClick={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallPluginView({
|
||||
filename,
|
||||
manifest,
|
||||
onInstall,
|
||||
onBack,
|
||||
}: {
|
||||
filename: string;
|
||||
manifest: PluginManifest | null;
|
||||
onInstall: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [send] = useJsonRpc();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
function handleInstall() {
|
||||
if (installing) return;
|
||||
setInstalling(true);
|
||||
send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => {
|
||||
if ("error" in resp) {
|
||||
setError(resp.error.message);
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(false);
|
||||
onInstall();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<ViewHeader
|
||||
title="Install Plugin"
|
||||
description={
|
||||
!manifest ?
|
||||
`Extracting plugin from ${filename}...` :
|
||||
`Do you want to install this plugin?`
|
||||
}
|
||||
/>
|
||||
{manifest && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<h3 className="text-lg font-semibold">{manifest.name}</h3>
|
||||
<p className="text-xs">{manifest.description}</p>
|
||||
<p className="text-xs">
|
||||
Version: {manifest.version}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<a
|
||||
href={manifest.homepage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
{manifest.homepage}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end w-full space-x-2">
|
||||
<Button
|
||||
size="MD"
|
||||
theme="light"
|
||||
text="Cancel"
|
||||
onClick={() => {
|
||||
// TODO: Delete the orphaned extraction
|
||||
setError(null);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="MD"
|
||||
theme="primary"
|
||||
text="Install"
|
||||
disabled={!manifest || installing}
|
||||
onClick={handleInstall}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorView({
|
||||
errorMessage,
|
||||
onClose,
|
||||
onRetry,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<ExclamationTriangleIcon className="w-6 h-6" />
|
||||
<h2 className="text-lg font-bold leading-tight">Plugin Extract Error</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-snug text-slate-600">
|
||||
An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again.
|
||||
</p>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<Card className="p-4 border border-red-200 bg-red-50">
|
||||
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button size="SM" theme="light" text="Close" onClick={onClose} />
|
||||
<Button size="SM" theme="primary" text="Back to Upload" onClick={onRetry} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -25,6 +25,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
|
|||
import { LocalDevice } from "@routes/devices.$id";
|
||||
import { useRevalidator } from "react-router-dom";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import PluginList from "@components/PluginList";
|
||||
|
||||
export function SettingsItem({
|
||||
title,
|
||||
|
@ -743,6 +744,14 @@ export default function SettingsSidebar() {
|
|||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Plugins"
|
||||
description="Manage installed plugins and their settings"
|
||||
/>
|
||||
<PluginList />
|
||||
</div>
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="pb-2 space-y-4">
|
||||
<SectionHeader
|
||||
title="Updates"
|
||||
|
|
|
@ -528,3 +528,63 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
|||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
||||
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
homepage: string;
|
||||
}
|
||||
|
||||
export interface PluginStatus extends PluginManifest {
|
||||
enabled: boolean;
|
||||
status: "stopped" | "running" | "loading" | "pending-configuration" | "error";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
isPluginUploadModalOpen: boolean;
|
||||
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
|
||||
|
||||
pluginUploadFilename: string | null;
|
||||
setPluginUploadFilename: (filename: string | null) => void;
|
||||
|
||||
pluginUploadManifest: PluginManifest | null;
|
||||
setPluginUploadManifest: (manifest: PluginManifest | null) => void;
|
||||
|
||||
pluginUploadModalView: "upload" | "install";
|
||||
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
|
||||
|
||||
plugins: PluginStatus[];
|
||||
setPlugins: (plugins: PluginStatus[]) => void;
|
||||
|
||||
pluginConfigureModalOpen: boolean;
|
||||
setPluginConfigureModalOpen: (isOpen: boolean) => void;
|
||||
|
||||
configuringPlugin: string | null;
|
||||
setConfiguringPlugin: (pluginName: string | null) => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>(set => ({
|
||||
isPluginUploadModalOpen: false,
|
||||
setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
|
||||
|
||||
pluginUploadFilename: null,
|
||||
setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }),
|
||||
|
||||
pluginUploadManifest: null,
|
||||
setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }),
|
||||
|
||||
pluginUploadModalView: "upload",
|
||||
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
|
||||
|
||||
plugins: [],
|
||||
setPlugins: plugins => set({ plugins }),
|
||||
|
||||
pluginConfigureModalOpen: false,
|
||||
setPluginConfigureModalOpen: isOpen => set({ pluginConfigureModalOpen: isOpen }),
|
||||
|
||||
configuringPlugin: null,
|
||||
setConfiguringPlugin: plugin => set({ configuringPlugin: plugin }),
|
||||
}));
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"kvm/internal/storage"
|
||||
"kvm/resource"
|
||||
"log"
|
||||
"net/http"
|
||||
|
@ -252,7 +253,7 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro
|
|||
}
|
||||
|
||||
func rpcMountWithStorage(filename string, mode VirtualMediaMode) error {
|
||||
filename, err := sanitizeFilename(filename)
|
||||
filename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -341,20 +342,8 @@ func rpcListStorageFiles() (*StorageFiles, error) {
|
|||
return &StorageFiles{Files: storageFiles}, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(filename string) (string, error) {
|
||||
cleanPath := filepath.Clean(filename)
|
||||
if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
sanitized := filepath.Base(cleanPath)
|
||||
if sanitized == "." || sanitized == string(filepath.Separator) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
return sanitized, nil
|
||||
}
|
||||
|
||||
func rpcDeleteStorageFile(filename string) error {
|
||||
sanitizedFilename, err := sanitizeFilename(filename)
|
||||
sanitizedFilename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -373,15 +362,10 @@ func rpcDeleteStorageFile(filename string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
type StorageFileUpload struct {
|
||||
AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"`
|
||||
DataChannel string `json:"dataChannel"`
|
||||
}
|
||||
|
||||
const uploadIdPrefix = "upload_"
|
||||
|
||||
func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, error) {
|
||||
sanitizedFilename, err := sanitizeFilename(filename)
|
||||
func rpcStartStorageFileUpload(filename string, size int64) (*storage.StorageFileUpload, error) {
|
||||
sanitizedFilename, err := storage.SanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -403,28 +387,19 @@ func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload,
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file for upload: %v", err)
|
||||
}
|
||||
pendingUploadsMutex.Lock()
|
||||
pendingUploads[uploadId] = pendingUpload{
|
||||
|
||||
storage.AddPendingUpload(uploadId, storage.PendingUpload{
|
||||
File: file,
|
||||
Size: size,
|
||||
AlreadyUploadedBytes: alreadyUploadedBytes,
|
||||
}
|
||||
pendingUploadsMutex.Unlock()
|
||||
return &StorageFileUpload{
|
||||
})
|
||||
|
||||
return &storage.StorageFileUpload{
|
||||
AlreadyUploadedBytes: alreadyUploadedBytes,
|
||||
DataChannel: uploadId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type pendingUpload struct {
|
||||
File *os.File
|
||||
Size int64
|
||||
AlreadyUploadedBytes int64
|
||||
}
|
||||
|
||||
var pendingUploads = make(map[string]pendingUpload)
|
||||
var pendingUploadsMutex sync.Mutex
|
||||
|
||||
type UploadProgress struct {
|
||||
Size int64
|
||||
AlreadyUploadedBytes int64
|
||||
|
@ -433,9 +408,7 @@ type UploadProgress struct {
|
|||
func handleUploadChannel(d *webrtc.DataChannel) {
|
||||
defer d.Close()
|
||||
uploadId := d.Label()
|
||||
pendingUploadsMutex.Lock()
|
||||
pendingUpload, ok := pendingUploads[uploadId]
|
||||
pendingUploadsMutex.Unlock()
|
||||
pendingUpload, ok := storage.GetPendingUpload(uploadId)
|
||||
if !ok {
|
||||
logger.Warnf("upload channel opened for unknown upload: %s", uploadId)
|
||||
return
|
||||
|
@ -454,9 +427,7 @@ func handleUploadChannel(d *webrtc.DataChannel) {
|
|||
} else {
|
||||
logger.Warnf("uploaded ended before the complete file received")
|
||||
}
|
||||
pendingUploadsMutex.Lock()
|
||||
delete(pendingUploads, uploadId)
|
||||
pendingUploadsMutex.Unlock()
|
||||
storage.DeletePendingUpload(uploadId)
|
||||
}()
|
||||
uploadComplete := make(chan struct{})
|
||||
lastProgressTime := time.Now()
|
||||
|
@ -502,9 +473,7 @@ func handleUploadChannel(d *webrtc.DataChannel) {
|
|||
|
||||
func handleUploadHttp(c *gin.Context) {
|
||||
uploadId := c.Query("uploadId")
|
||||
pendingUploadsMutex.Lock()
|
||||
pendingUpload, ok := pendingUploads[uploadId]
|
||||
pendingUploadsMutex.Unlock()
|
||||
pendingUpload, ok := storage.GetPendingUpload(uploadId)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Upload not found"})
|
||||
return
|
||||
|
@ -524,9 +493,7 @@ func handleUploadHttp(c *gin.Context) {
|
|||
} else {
|
||||
logger.Warnf("uploaded ended before the complete file received")
|
||||
}
|
||||
pendingUploadsMutex.Lock()
|
||||
delete(pendingUploads, uploadId)
|
||||
pendingUploadsMutex.Unlock()
|
||||
storage.DeletePendingUpload(uploadId)
|
||||
}()
|
||||
|
||||
reader := c.Request.Body
|
||||
|
|
|
@ -102,8 +102,9 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
switch d.Label() {
|
||||
case "rpc":
|
||||
session.RPCChannel = d
|
||||
rpcServer := NewDataChannelJsonRpcRouter(d)
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
go onRPCMessage(msg, session)
|
||||
go rpcServer.HandleMessage(msg.Data)
|
||||
})
|
||||
triggerOTAStateUpdate()
|
||||
triggerVideoStateUpdate()
|
||||
|
|
Loading…
Reference in New Issue