From 6253afe8ebd5c9dc26e4147e1e9a66972d95f411 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:46:36 +0000 Subject: [PATCH 01/24] Add devcontainer support --- .devcontainer/devcontainer.json | 10 ++++++++ Makefile | 2 +- dev_deploy.sh | 42 +++++++-------------------------- 3 files changed, 19 insertions(+), 35 deletions(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e96e24b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "JetKVM", + "image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + // Should match what is defined in ui/package.json + "version": "21.1.0" + } + } +} diff --git a/Makefile b/Makefile index 5c03635..04c7402 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build_dev: hash_resource GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go frontend: - cd ui && npm run build:device + cd ui && npm ci && npm run build:device dev_release: build_dev @echo "Uploading release..." diff --git a/dev_deploy.sh b/dev_deploy.sh index 1d6aa82..a106395 100755 --- a/dev_deploy.sh +++ b/dev_deploy.sh @@ -3,35 +3,28 @@ set -e # Function to display help message show_help() { - echo "Usage: $0 [options] -h -r " + echo "Usage: $0 [options] -r " echo echo "Required:" - echo " -h, --host Local host IP address" echo " -r, --remote Remote host IP address" echo echo "Optional:" echo " -u, --user Remote username (default: root)" - echo " -p, --port Python server port (default: 8000)" echo " --help Display this help message" echo echo "Example:" - echo " $0 -h 192.168.0.13 -r 192.168.0.17" - echo " $0 -h 192.168.0.13 -r 192.168.0.17 -u admin -p 8080" + echo " $0 -r 192.168.0.17" + echo " $0 -r 192.168.0.17 -u admin" exit 0 } # Default values -PYTHON_PORT=8000 REMOTE_USER="root" REMOTE_PATH="/userdata/jetkvm/bin" # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in - -h|--host) - HOST_IP="$2" - shift 2 - ;; -r|--remote) REMOTE_HOST="$2" shift 2 @@ -40,10 +33,6 @@ while [[ $# -gt 0 ]]; do REMOTE_USER="$2" shift 2 ;; - -p|--port) - PYTHON_PORT="$2" - shift 2 - ;; --help) show_help exit 0 @@ -57,8 +46,8 @@ while [[ $# -gt 0 ]]; do done # Verify required parameters -if [ -z "$HOST_IP" ] || [ -z "$REMOTE_HOST" ]; then - echo "Error: Host IP and Remote IP are required parameters" +if [ -z "$REMOTE_HOST" ]; then + echo "Error: Remote IP is a required parameter" show_help fi @@ -69,12 +58,8 @@ make build_dev # Change directory to the binary output directory cd bin -# Start a Python HTTP server in the background to serve files -python3 -m http.server "$PYTHON_PORT" & -PYTHON_SERVER_PID=$! - -# Ensure that the Python server is terminated if the script exits unexpectedly -trap "echo 'Terminating Python server...'; kill $PYTHON_SERVER_PID" EXIT +# Copy the binary to the remote host +cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug" # Deploy and run the application on the remote host ssh "${REMOTE_USER}@${REMOTE_HOST}" ash < Date: Wed, 1 Jan 2025 17:52:26 +0000 Subject: [PATCH 02/24] Add dev:device script and support for setting JETKVM_PROXY_URL for development --- ui/package.json | 1 + ui/vite.config.ts | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/ui/package.json b/ui/package.json index 592a300..9a7fae5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ "dev": "vite dev --mode=development", "build": "npm run build:prod", "build:device": "tsc && vite build --mode=device --emptyOutDir", + "dev:device": "vite dev --mode=device", "build:prod": "tsc && vite build --mode=production", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e9c7fe5..b6d26f6 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,13 +2,31 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig(({ mode }) => { +declare const process: { + env: { + JETKVM_PROXY_URL: string; + }; +}; + +export default defineConfig(({ mode, command }) => { const isCloud = mode === "production"; const onDevice = mode === "device"; + const { JETKVM_PROXY_URL } = process.env; + return { plugins: [tsconfigPaths(), react()], build: { outDir: isCloud ? "dist" : "../static" }, - server: { host: "0.0.0.0" }, - base: onDevice ? "/static" : "/", + server: { + host: "0.0.0.0", + proxy: JETKVM_PROXY_URL ? { + '/me': JETKVM_PROXY_URL, + '/device': JETKVM_PROXY_URL, + '/webrtc': JETKVM_PROXY_URL, + '/auth': JETKVM_PROXY_URL, + '/storage': JETKVM_PROXY_URL, + '/cloud': JETKVM_PROXY_URL, + } : undefined + }, + base: onDevice && command === 'build' ? "/static" : "/", }; }); From 377c3e89c0449c81dc4271424282204b11d5c020 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Wed, 1 Jan 2025 19:35:39 +0000 Subject: [PATCH 03/24] Implement plugin upload support and placeholder settings item --- internal/plugin/plugin.go | 53 +++ internal/storage/type.go | 6 + internal/storage/uploads.go | 34 ++ internal/storage/utils.go | 19 + jsonrpc.go | 2 + ui/src/components/MountMediaDialog.tsx | 2 +- ui/src/components/UploadPluginDialog.tsx | 482 +++++++++++++++++++++++ ui/src/components/sidebar/settings.tsx | 42 ++ ui/src/hooks/stores.ts | 11 + usb_mass_storage.go | 61 +-- 10 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 internal/plugin/plugin.go create mode 100644 internal/storage/type.go create mode 100644 internal/storage/uploads.go create mode 100644 internal/storage/utils.go create mode 100644 ui/src/components/UploadPluginDialog.tsx diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..a794bba --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,53 @@ +package plugin + +import ( + "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) +} + +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 +} diff --git a/internal/storage/type.go b/internal/storage/type.go new file mode 100644 index 0000000..ba7a123 --- /dev/null +++ b/internal/storage/type.go @@ -0,0 +1,6 @@ +package storage + +type StorageFileUpload struct { + AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"` + DataChannel string `json:"dataChannel"` +} diff --git a/internal/storage/uploads.go b/internal/storage/uploads.go new file mode 100644 index 0000000..48fdaf7 --- /dev/null +++ b/internal/storage/uploads.go @@ -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) +} diff --git a/internal/storage/utils.go b/internal/storage/utils.go new file mode 100644 index 0000000..e622fc2 --- /dev/null +++ b/internal/storage/utils.go @@ -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 +} diff --git a/jsonrpc.go b/jsonrpc.go index 2ce5f18..34d6ef9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "kvm/internal/plugin" "log" "os" "os/exec" @@ -554,4 +555,5 @@ 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"}}, } diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 505d233..8deb4a5 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -1516,7 +1516,7 @@ function PreUploadedImageItem({ ); } -function ViewHeader({ title, description }: { title: string; description: string }) { +export function ViewHeader({ title, description }: { title: string; description: string }) { return (

diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx new file mode 100644 index 0000000..5fb950f --- /dev/null +++ b/ui/src/components/UploadPluginDialog.tsx @@ -0,0 +1,482 @@ +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 { + 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 notifications from "../notifications"; +import { isOnDevice } from "../main"; +import { ViewHeader } from "./MountMediaDialog"; + +export default function UploadPluginModal({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ); +} + +function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + return ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo + + { + setOpen(false) + }} + onCancelUpload={() => { + setOpen(false) + }} + /> +
+
+
+
+
+ ); +} + +// 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, + onCancelUpload, +}: { + onBack: () => void; + onCancelUpload: () => void; +}) { + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( + "idle", + ); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [uploadedFileSize, setUploadedFileSize] = useState(null); + const [uploadSpeed, setUploadSpeed] = useState(null); + const [fileError, setFileError] = useState(null); + const [uploadError, setUploadError] = useState(null); + + const [send] = useJsonRpc(); + const rtcDataChannelRef = useRef(null); + + useEffect(() => { + const ref = rtcDataChannelRef.current; + return () => { + if (ref) { + ref.onopen = null; + ref.onerror = null; + ref.onmessage = null; + ref.onclose = null; + ref.close(); + } + }; + }, []); + + function handleWebRTCUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const rtcDataChannel = useRTCStore + .getState() + .peerConnection?.createDataChannel(dataChannel); + + if (!rtcDataChannel) { + console.error("Failed to create data channel for file upload"); + notifications.error("Failed to create data channel for file upload"); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + + return; + } + + rtcDataChannelRef.current = rtcDataChannel; + + const lowWaterMark = 256 * 1024; + const highWaterMark = 1 * 1024 * 1024; + rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + rtcDataChannel.onmessage = e => { + try { + const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { + AlreadyUploadedBytes: number; + Size: number; + }; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((AlreadyUploadedBytes / Size) * 100); + } + + lastUploadedBytes = AlreadyUploadedBytes; + lastUpdateTime = now; + } catch (e) { + console.error("Error processing RTC Data channel message:", e); + } + }; + + rtcDataChannel.onopen = () => { + let pauseSending = false; // Pause sending when the buffered amount is high + const chunkSize = 4 * 1024; // 4KB chunks + + let offset = alreadyUploadedBytes; + const sendNextChunk = () => { + if (offset >= file.size) { + rtcDataChannel.close(); + setUploadState("success"); + return; + } + + if (pauseSending) return; + + const chunk = file.slice(offset, offset + chunkSize); + chunk.arrayBuffer().then(buffer => { + rtcDataChannel.send(buffer); + + if (rtcDataChannel.bufferedAmount >= highWaterMark) { + pauseSending = true; + } + + offset += buffer.byteLength; + console.log(`Chunk sent: ${offset} / ${file.size} bytes`); + sendNextChunk(); + }); + }; + + sendNextChunk(); + rtcDataChannel.onbufferedamountlow = () => { + console.log("RTC Data channel buffered amount low"); + pauseSending = false; // Now the data channel is ready to send more data + sendNextChunk(); + }; + }; + + rtcDataChannel.onerror = error => { + console.error("RTC Data channel error:", error); + notifications.error(`Upload failed: ${error}`); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + }; + } + + async function handleHttpUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const uploadUrl = `${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"); + } else { + console.error("Upload error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + } + }; + + xhr.onerror = () => { + console.error("XHR error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + }; + + // Prepare the data to send + const blob = file.slice(alreadyUploadedBytes); + + // Send the file data + xhr.send(blob); + } + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Reset the upload error when a new file is selected + setUploadError(null); + + 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 ( +
+ +
+
{ + if (uploadState === "idle") { + document.getElementById("file-upload")?.click(); + } + }} + className="block select-none" + > +
+ +
+
+ {uploadState === "idle" && ( +
+
+ +
+ +
+
+
+

+ Click to select a file +

+

+ Supported formats: TAR, TAR.GZ +

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

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

+

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

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

+ Upload successful +

+

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

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

{fileError}

} +
+ + {/* Display upload error if present */} + {uploadError && ( +
+ Error: {uploadError} +
+ )} + +
+
+ {uploadState === "uploading" ? ( +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index ec606a6..bb9439a 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -4,6 +4,7 @@ import { useSettingsStore, useUiStore, useUpdateStore, + usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -25,6 +26,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 UploadPluginModal from "@components/UploadPluginDialog"; export function SettingsItem({ title, @@ -251,6 +253,8 @@ export default function SettingsSidebar() { } }; + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + useEffect(() => { getCloudState(); @@ -743,6 +747,44 @@ export default function SettingsSidebar() {
) : null} +
+ +
    +
  • +
    +
    +
    +
    +

    Tailscale

    +

    https://github.com/tutman96/jetkvm-plugin-tailscale

    +
    +
    +
    +
  • +
+
+
+
+
(set => ({ setModalView: view => set({ modalView: view }), setErrorMessage: message => set({ errorMessage: message }), })); + + +interface PluginState { + isPluginUploadModalOpen: boolean; + setIsPluginUploadModalOpen: (isOpen: boolean) => void; +} + +export const usePluginStore = create(set => ({ + isPluginUploadModalOpen: false, + setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), +})); \ No newline at end of file diff --git a/usb_mass_storage.go b/usb_mass_storage.go index b897c20..b72ab97 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -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 From 0a772005dc1508c7e4f3f174d24f45e290058d83 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Wed, 1 Jan 2025 22:34:59 +0000 Subject: [PATCH 04/24] Add extracting and validating the plugin --- internal/plugin/database.go | 54 ++++++ internal/plugin/extract.go | 83 +++++++++ internal/plugin/plugin.go | 122 +++++++++++++- internal/plugin/type.go | 30 ++++ jsonrpc.go | 2 + ui/src/components/UploadPluginDialog.tsx | 205 ++++++++++++++++++++++- ui/src/components/sidebar/settings.tsx | 7 +- ui/src/hooks/stores.ts | 27 ++- 8 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 internal/plugin/database.go create mode 100644 internal/plugin/extract.go create mode 100644 internal/plugin/type.go diff --git a/internal/plugin/database.go b/internal/plugin/database.go new file mode 100644 index 0000000..d5d6f60 --- /dev/null +++ b/internal/plugin/database.go @@ -0,0 +1,54 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" +) + +const databaseFile = pluginsFolder + "/plugins.json" + +var pluginDatabase = PluginDatabase{} + +func init() { + if err := pluginDatabase.Load(); err != nil { + fmt.Printf("failed to load plugin database: %v\n", err) + } +} + +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) + if err != nil { + return fmt.Errorf("failed to create plugin database: %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) + } + + return nil +} diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go new file mode 100644 index 0000000..45508e9 --- /dev/null +++ b/internal/plugin/extract.go @@ -0,0 +1,83 @@ +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 nil, 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 nil, 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 nil, fmt.Errorf("failed to create extracts folder: %v", err) + } + + tarReader := tar.NewReader(reader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, 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 nil, 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 nil, 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 nil, fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + if _, err := io.Copy(file, tarReader); err != nil { + return nil, fmt.Errorf("failed to extract file: %v", err) + } + default: + return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag) + } + } + + return &destinationFolder, nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a794bba..3a3318d 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/json" "fmt" "kvm/internal/storage" "os" @@ -10,7 +11,7 @@ import ( ) const pluginsFolder = "/userdata/jetkvm/plugins" -const pluginsUploadFolder = pluginsFolder + "/_uploads" +const pluginsUploadFolder = pluginsFolder + "/uploads" func init() { _ = os.MkdirAll(pluginsUploadFolder, 0755) @@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo 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 { + // TODO: find the plugin version in the plugins.json file + 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) + } + + // TODO: If there is a running plugin with the same name, stop it and start the new 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) + } + // TODO: start the plugin + + // TODO: Determine if the old version should be removed + + 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 +} diff --git a/internal/plugin/type.go b/internal/plugin/type.go new file mode 100644 index 0000000..6f07c59 --- /dev/null +++ b/internal/plugin/type.go @@ -0,0 +1,30 @@ +package plugin + +import "sync" + +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 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"` +} + +type PluginDatabase struct { + // Map with the plugin name as the key + Plugins map[string]PluginInstall `json:"plugins"` + + saveMutex sync.Mutex +} diff --git a/jsonrpc.go b/jsonrpc.go index 34d6ef9..6ffdd87 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -556,4 +556,6 @@ var rpcHandlers = map[string]RPCHandler{ "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"}}, } diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 5fb950f..5cbd4ad 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -5,6 +5,8 @@ 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"; @@ -16,6 +18,7 @@ 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"; @@ -35,6 +38,28 @@ export default function UploadPluginModal({ } function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const { + pluginUploadModalView, + setPluginUploadModalView, + pluginUploadFilename, + setPluginUploadFilename, + pluginUploadManifest, + setPluginUploadManifest, + } = usePluginStore(); + const [send] = useJsonRpc(); + const [extractError, setExtractError] = useState(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 (
void }) { className="h-[24px] dark:block hidden dark:!mt-0" /> - { setOpen(false) }} - onCancelUpload={() => { - setOpen(false) + onUploadCompleted={(filename) => { + setPluginUploadFilename(filename) + setPluginUploadModalView("install") + extractPlugin(filename) }} - /> + />} + + {extractError && ( + { + setOpen(false) + setPluginUploadFilename(null) + setExtractError(null) + }} + onRetry={() => { + setExtractError(null) + setPluginUploadFilename(null) + setPluginUploadModalView("upload") + }} + /> + )} + + {!extractError && pluginUploadModalView === "install" && { + setOpen(false) + setPluginUploadFilename(null) + // TODO: Open plugin settings dialog + }} + onBack={() => { + setPluginUploadModalView("upload") + setPluginUploadFilename(null) + }} + />}
@@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { // TODO: refactor to a shared component function UploadFileView({ onBack, - onCancelUpload, + onUploadCompleted, }: { onBack: () => void; - onCancelUpload: () => void; + onUploadCompleted: (filename: string) => void; }) { const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( "idle", @@ -177,6 +234,7 @@ function UploadFileView({ if (offset >= file.size) { rtcDataChannel.close(); setUploadState("success"); + onUploadCompleted(file.name); return; } @@ -260,6 +318,7 @@ function UploadFileView({ xhr.onload = () => { if (xhr.status === 200) { setUploadState("success"); + onUploadCompleted(file.name); } else { console.error("Upload error:", xhr.statusText); setUploadError(xhr.statusText); @@ -459,7 +518,7 @@ function UploadFileView({ theme="light" text="Cancel Upload" onClick={() => { - onCancelUpload(); + onBack(); setUploadState("idle"); setUploadProgress(0); setUploadedFileName(null); @@ -470,7 +529,7 @@ function UploadFileView({ ) : (
); +} + +function InstallPluginView({ + filename, + manifest, + onInstall, + onBack, +}: { + filename: string; + manifest: PluginManifest | null; + onInstall: () => void; + onBack: () => void; +}) { + const [send] = useJsonRpc(); + const [error, setError] = useState(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 ( +
+ + {manifest && ( +
+
+

{manifest.name}

+

{manifest.description}

+

+ Version: {manifest.version} +

+

+ + {manifest.homepage} + +

+
+
+ )} + {error && ( +
+ Error: {error} +
+ )} +
+
+
+
+
+ ); +} + +function ErrorView({ + errorMessage, + onClose, + onRetry, +}: { + errorMessage: string | null; + onClose: () => void; + onRetry: () => void; +}) { + return ( +
+
+
+ +

Plugin Extract Error

+
+

+ An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again. +

+
+ {errorMessage && ( + +

{errorMessage}

+
+ )} +
+
+
+ ); } \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index bb9439a..769b943 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -253,7 +253,7 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); useEffect(() => { getCloudState(); @@ -776,7 +776,10 @@ export default function SettingsSidebar() { size="SM" theme="primary" text="Upload Plugin" - onClick={() => setIsPluginUploadModalOpen(true)} + onClick={() => { + setPluginUploadModalView("upload"); + setIsPluginUploadModalOpen(true) + }} /> (set => ({ })); +export interface PluginManifest { + name: string; + version: string; + description?: string; + homepage: 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; } export const usePluginStore = create(set => ({ isPluginUploadModalOpen: false, setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), -})); \ No newline at end of file + + pluginUploadFilename: null, + setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }), + + pluginUploadManifest: null, + setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }), + + pluginUploadModalView: "upload", + setPluginUploadModalView: view => set({ pluginUploadModalView: view }), +})); From 00fdbafeb7f93eb9812236e71f2877bd3efba4af Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 15:53:07 +0000 Subject: [PATCH 05/24] Write plugin database to tmp file first --- internal/plugin/database.go | 8 ++++++-- ui/src/components/UploadPluginDialog.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/plugin/database.go b/internal/plugin/database.go index d5d6f60..2f9a89f 100644 --- a/internal/plugin/database.go +++ b/internal/plugin/database.go @@ -38,9 +38,9 @@ func (d *PluginDatabase) Save() error { d.saveMutex.Lock() defer d.saveMutex.Unlock() - file, err := os.Create(databaseFile) + file, err := os.Create(databaseFile + ".tmp") if err != nil { - return fmt.Errorf("failed to create plugin database: %v", err) + return fmt.Errorf("failed to create plugin database tmp: %v", err) } defer file.Close() @@ -50,5 +50,9 @@ func (d *PluginDatabase) Save() error { 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 } diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 5cbd4ad..b0c4f02 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -576,7 +576,7 @@ function InstallPluginView({ description={ !manifest ? `Extracting plugin from ${filename}...` : - `Do you want to install the plugin?` + `Do you want to install this plugin?` } /> {manifest && ( From 3853b58613902780ce8199ce67fd972d8b7f44a5 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 16:43:09 +0000 Subject: [PATCH 06/24] Implement pluginList RPC and associated UI --- internal/plugin/database.go | 8 ++ internal/plugin/install.go | 31 +++++++ internal/plugin/plugin.go | 22 +++++ internal/plugin/type.go | 21 +---- jsonrpc.go | 1 + ui/src/components/PluginList.tsx | 107 +++++++++++++++++++++++++ ui/src/components/sidebar/settings.tsx | 40 +-------- ui/src/hooks/stores.ts | 11 +++ 8 files changed, 186 insertions(+), 55 deletions(-) create mode 100644 internal/plugin/install.go create mode 100644 ui/src/components/PluginList.tsx diff --git a/internal/plugin/database.go b/internal/plugin/database.go index 2f9a89f..f97e748 100644 --- a/internal/plugin/database.go +++ b/internal/plugin/database.go @@ -4,10 +4,18 @@ import ( "encoding/json" "fmt" "os" + "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 init() { diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 0000000..c860aff --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,31 @@ +package plugin + +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 +} + +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] +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 3a3318d..b841f01 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -132,6 +132,28 @@ func RpcPluginInstall(name string, version string) error { return nil } +func RpcPluginList() ([]PluginStatus, error) { + plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) + for pluginName, plugin := range pluginDatabase.Plugins { + manifest, err := plugin.GetManifest() + if err != nil { + return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err) + } + + status := "stopped" + if plugin.Enabled { + status = "running" + } + + plugins = append(plugins, PluginStatus{ + PluginManifest: *manifest, + Enabled: plugin.Enabled, + Status: status, + }) + } + return plugins, nil +} + func readManifest(extractFolder string) (*PluginManifest, error) { manifestPath := path.Join(extractFolder, "manifest.json") manifestFile, err := os.Open(manifestPath) diff --git a/internal/plugin/type.go b/internal/plugin/type.go index 6f07c59..01d85a5 100644 --- a/internal/plugin/type.go +++ b/internal/plugin/type.go @@ -1,7 +1,5 @@ package plugin -import "sync" - type PluginManifest struct { ManifestVersion string `json:"manifest_version"` Name string `json:"name"` @@ -12,19 +10,8 @@ type PluginManifest struct { SystemMinVersion string `json:"system_min_version,omitempty"` } -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"` -} - -type PluginDatabase struct { - // Map with the plugin name as the key - Plugins map[string]PluginInstall `json:"plugins"` - - saveMutex sync.Mutex +type PluginStatus struct { + PluginManifest + Enabled bool `json:"enabled"` + Status string `json:"status"` } diff --git a/jsonrpc.go b/jsonrpc.go index 6ffdd87..9f3a9b2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -558,4 +558,5 @@ var rpcHandlers = map[string]RPCHandler{ "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}, } diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx new file mode 100644 index 0000000..947e618 --- /dev/null +++ b/ui/src/components/PluginList.tsx @@ -0,0 +1,107 @@ +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { Button } from "@components/Button"; +import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; +import { useCallback, useEffect, useState } from "react"; +import { cx } from "@/cva.config"; +import UploadPluginModal from "@components/UploadPluginDialog"; + +function PluginListStatusIcon({ 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 === "stopped") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
+
+
+ ) +} + +export default function PluginList() { + const [send] = useJsonRpc(); + const [error, setError] = useState(null); + + const { + isPluginUploadModalOpen, + setIsPluginUploadModalOpen, + setPluginUploadModalView, + plugins, + setPlugins + } = usePluginStore(); + const sidebarView = useUiStore(state => state.sidebarView); + + const updatePlugins = useCallback(() => { + send("pluginList", {}, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + setPlugins(resp.result as PluginStatus[]); + }); + }, [send, setPlugins]) + + useEffect(() => { + // Only update plugins when the sidebar view is the settings view + if (sidebarView !== "system") return; + updatePlugins(); + + const updateInterval = setInterval(() => { + updatePlugins(); + }, 10_000); + return () => clearInterval(updateInterval); + }, [updatePlugins, sidebarView]) + + return ( + <> +
+
    + {error &&
  • {error}
  • } + {plugins.length === 0 &&
  • No plugins installed
  • } + {plugins.map(plugin => ( +
  • + +
    +

    {plugin.name}

    +

    + {plugin.homepage} +

    +
    +
    +
    +
  • + ))} +
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 769b943..84170d0 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -4,7 +4,6 @@ import { useSettingsStore, useUiStore, useUpdateStore, - usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -26,7 +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 UploadPluginModal from "@components/UploadPluginDialog"; +import PluginList from "@components/PluginList"; export function SettingsItem({ title, @@ -253,8 +252,6 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); - useEffect(() => { getCloudState(); @@ -752,40 +749,7 @@ export default function SettingsSidebar() { title="Plugins" description="Manage installed plugins and their settings" /> -
    -
  • -
    -
    -
    -
    -

    Tailscale

    -

    https://github.com/tutman96/jetkvm-plugin-tailscale

    -
    -
    -
    -
  • -
-
-
+
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 9acaaf7..e0543ec 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -537,6 +537,11 @@ export interface PluginManifest { homepage: string; } +export interface PluginStatus extends PluginManifest { + enabled: boolean; + status: "stopped" | "running"; +} + interface PluginState { isPluginUploadModalOpen: boolean; setIsPluginUploadModalOpen: (isOpen: boolean) => void; @@ -549,6 +554,9 @@ interface PluginState { pluginUploadModalView: "upload" | "install"; setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void; + + plugins: PluginStatus[]; + setPlugins: (plugins: PluginStatus[]) => void; } export const usePluginStore = create(set => ({ @@ -563,4 +571,7 @@ export const usePluginStore = create(set => ({ pluginUploadModalView: "upload", setPluginUploadModalView: view => set({ pluginUploadModalView: view }), + + plugins: [], + setPlugins: plugins => set({ plugins }), })); From 88f3e97011b4be07f37a6a003a4398717ff1adac Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 17:18:25 +0000 Subject: [PATCH 07/24] Add enable/disable button --- internal/plugin/install.go | 20 +++ internal/plugin/plugin.go | 36 ++++-- jsonrpc.go | 1 + ui/src/components/PluginConfigureDialog.tsx | 127 ++++++++++++++++++++ ui/src/components/PluginList.tsx | 23 +++- ui/src/hooks/stores.ts | 12 ++ 6 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 ui/src/components/PluginConfigureDialog.tsx diff --git a/internal/plugin/install.go b/internal/plugin/install.go index c860aff..01d8f25 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -1,5 +1,7 @@ package plugin +import "fmt" + type PluginInstall struct { Enabled bool `json:"enabled"` @@ -29,3 +31,21 @@ func (p *PluginInstall) GetManifest() (*PluginManifest, error) { 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 := "stopped" + if p.Enabled { + status = "running" + } + + return &PluginStatus{ + PluginManifest: *manifest, + Enabled: p.Enabled, + Status: status, + }, nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index b841f01..a161e68 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -135,25 +135,35 @@ func RpcPluginInstall(name string, version string) error { func RpcPluginList() ([]PluginStatus, error) { plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) for pluginName, plugin := range pluginDatabase.Plugins { - manifest, err := plugin.GetManifest() + status, err := plugin.GetStatus() if err != nil { - return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err) + return nil, fmt.Errorf("failed to get plugin status for %s: %v", pluginName, err) } - - status := "stopped" - if plugin.Enabled { - status = "running" - } - - plugins = append(plugins, PluginStatus{ - PluginManifest: *manifest, - Enabled: plugin.Enabled, - Status: status, - }) + plugins = append(plugins, *status) } return plugins, nil } +func RpcUpdateConfig(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) + } + + 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 readManifest(extractFolder string) (*PluginManifest, error) { manifestPath := path.Join(extractFolder, "manifest.json") manifestFile, err := os.Open(manifestPath) diff --git a/jsonrpc.go b/jsonrpc.go index 9f3a9b2..05babd5 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -559,4 +559,5 @@ var rpcHandlers = map[string]RPCHandler{ "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, "pluginList": {Func: plugin.RpcPluginList}, + "pluginUpdateConfig": {Func: plugin.RpcUpdateConfig, Params: []string{"name", "enabled"}}, } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx new file mode 100644 index 0000000..c53eb89 --- /dev/null +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -0,0 +1,127 @@ +import { PluginStatus } from "@/hooks/stores"; +import Modal from "@components/Modal"; +import AutoHeight from "@components/AutoHeight"; +import { 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"; + +export default function PluginConfigureModal({ + plugin, + open, + setOpen, +}: { + plugin: PluginStatus | null; + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ) +} + +function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (open: boolean) => void }) { + const [send] = useJsonRpc(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + 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) { + setError(resp.error.message); + return + } + setOpen(false); + }); + }, [send, plugin, setOpen]) + + return ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo +
+
+ +
+ {/* Enable/Disable toggle */} +
+
+ +
+ {error &&

{error}

} +

+ TODO: Plugin configuration goes here +

+ +
+
+
+
+
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index 947e618..d2397f1 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -4,6 +4,7 @@ import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; +import PluginConfigureModal from "./PluginConfigureDialog"; function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { let classNames = "bg-slate-500 border-slate-600"; @@ -29,7 +30,11 @@ export default function PluginList() { setIsPluginUploadModalOpen, setPluginUploadModalView, plugins, - setPlugins + setPlugins, + pluginConfigureModalOpen, + setPluginConfigureModalOpen, + configuringPlugin, + setConfiguringPlugin, } = usePluginStore(); const sidebarView = useUiStore(state => state.sidebarView); @@ -74,7 +79,10 @@ export default function PluginList() { size="SM" theme="light" text="Settings" - onClick={() => console.log("Settings clicked")} + onClick={() => { + setConfiguringPlugin(plugin); + setPluginConfigureModalOpen(true); + }} />
@@ -82,6 +90,17 @@ export default function PluginList() {
+ { + setPluginConfigureModalOpen(open); + if (!open) { + updatePlugins(); + } + }} + plugin={configuringPlugin} + /> +
From e7640006972626e23d675dde366b4c511957128d Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:44:34 +0000 Subject: [PATCH 14/24] Golang standards :) --- internal/plugin/process_manager.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/plugin/process_manager.go b/internal/plugin/process_manager.go index 31bd53a..9d647d8 100644 --- a/internal/plugin/process_manager.go +++ b/internal/plugin/process_manager.go @@ -9,8 +9,10 @@ import ( ) // TODO: this can probably be defaulted to this, but overwritten on a per-plugin basis -const GRACEFUL_SHUTDOWN_DELAY = 30 * time.Second -const MAX_RESTART_BACKOFF = 30 * time.Second +const ( + gracefulShutdownDelay = 30 * time.Second + maxRestartBackoff = 30 * time.Second +) type ProcessManager struct { cmdGen func() *exec.Cmd @@ -75,8 +77,8 @@ func (pm *ProcessManager) scheduleRestart() { log.Printf("Restarting process in %v...", pm.backoff) time.Sleep(pm.backoff) pm.backoff *= 2 // Exponential backoff - if pm.backoff > MAX_RESTART_BACKOFF { - pm.backoff = MAX_RESTART_BACKOFF + if pm.backoff > maxRestartBackoff { + pm.backoff = maxRestartBackoff } pm.restartCh <- struct{}{} } @@ -87,7 +89,7 @@ func (pm *ProcessManager) terminate() { log.Printf("Sending SIGTERM...") pm.cmd.Process.Signal(syscall.SIGTERM) select { - case <-time.After(GRACEFUL_SHUTDOWN_DELAY): + case <-time.After(gracefulShutdownDelay): log.Printf("Forcing process termination...") pm.cmd.Process.Kill() case <-pm.waitForExit(): From 27b3395d0fb712df3df6f1a1a1104eb61b6dff8d Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:46:21 +0000 Subject: [PATCH 15/24] Newlines for all things --- ui/src/components/PluginConfigureDialog.tsx | 2 +- ui/src/components/PluginList.tsx | 2 +- ui/src/components/UploadPluginDialog.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 04d139f..c5808f1 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -151,4 +151,4 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op

) -} \ No newline at end of file +} diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index d5d5483..f2f0bdd 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -124,4 +124,4 @@ export default function PluginList() { ); -} \ No newline at end of file +} diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 6ba8d99..b1c967e 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -669,4 +669,4 @@ function ErrorView({ ); -} \ No newline at end of file +} From 0b3cd59e36a631f09fc5e57faf22cf0b1989600a Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 20:32:20 +0000 Subject: [PATCH 16/24] Refactor jsonrpc server into its own package --- internal/jsonrpc/rpc_server.go | 179 +++++++++++++++++++++++++++ internal/jsonrpc/types.go | 26 ++++ jsonrpc.go | 206 ++++--------------------------- ui/src/components/PluginList.tsx | 2 +- webrtc.go | 3 +- 5 files changed, 230 insertions(+), 186 deletions(-) create mode 100644 internal/jsonrpc/rpc_server.go create mode 100644 internal/jsonrpc/types.go diff --git a/internal/jsonrpc/rpc_server.go b/internal/jsonrpc/rpc_server.go new file mode 100644 index 0000000..bdaef1b --- /dev/null +++ b/internal/jsonrpc/rpc_server.go @@ -0,0 +1,179 @@ +package jsonrpc + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "reflect" +) + +type JSONRPCServer struct { + writer io.Writer + + handlers map[string]*RPCHandler +} + +func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { + return &JSONRPCServer{ + writer: writer, + handlers: handlers, + } +} + +func (s *JSONRPCServer) HandleMessage(data []byte) error { + var request JSONRPCRequest + err := json.Unmarshal(data, &request) + if err != nil { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]interface{}{ + "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: map[string]interface{}{ + "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: map[string]interface{}{ + "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 *JSONRPCServer) 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") +} diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go new file mode 100644 index 0000000..30f8a2c --- /dev/null +++ b/internal/jsonrpc/types.go @@ -0,0 +1,26 @@ +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 interface{} `json:"error,omitempty"` + ID interface{} `json:"id"` +} + +type JSONRPCEvent struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type RPCHandler struct { + Func interface{} + Params []string +} diff --git a/jsonrpc.go b/jsonrpc.go index aea624c..55df308 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,51 +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 NewDataChannelJsonRpcServer(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCServer { + return jsonrpc.NewJSONRPCServer( + 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, @@ -70,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 } @@ -316,108 +256,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 @@ -509,7 +347,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}, diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index f2f0bdd..96af34a 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -4,7 +4,7 @@ import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; -import PluginConfigureModal from "./PluginConfigureDialog"; +import PluginConfigureModal from "@components/PluginConfigureDialog"; function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { let classNames = "bg-slate-500 border-slate-600"; diff --git a/webrtc.go b/webrtc.go index 20ffb99..6984192 100644 --- a/webrtc.go +++ b/webrtc.go @@ -75,8 +75,9 @@ func newSession() (*Session, error) { switch d.Label() { case "rpc": session.RPCChannel = d + rpcServer := NewDataChannelJsonRpcServer(d) d.OnMessage(func(msg webrtc.DataChannelMessage) { - go onRPCMessage(msg, session) + rpcServer.HandleMessage(msg.Data) }) triggerOTAStateUpdate() triggerVideoStateUpdate() From e61decfb330cae69c704999458b1d99438b80cb2 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:38:54 +0000 Subject: [PATCH 17/24] wip: Plugin RPC with status reporting to the UI --- internal/jsonrpc/rpc_server.go | 149 ++++++++++++++++-- internal/jsonrpc/types.go | 14 +- internal/plugin/install.go | 48 +++--- internal/plugin/rpc.go | 166 ++++++++++++++++++++ internal/plugin/type.go | 2 +- ui/src/components/PluginConfigureDialog.tsx | 59 +++++-- ui/src/components/PluginList.tsx | 26 +-- ui/src/components/PluginStatusIcon.tsx | 19 +++ ui/src/hooks/stores.ts | 4 +- 9 files changed, 412 insertions(+), 75 deletions(-) create mode 100644 internal/plugin/rpc.go create mode 100644 ui/src/components/PluginStatusIcon.tsx diff --git a/internal/jsonrpc/rpc_server.go b/internal/jsonrpc/rpc_server.go index bdaef1b..5f8e870 100644 --- a/internal/jsonrpc/rpc_server.go +++ b/internal/jsonrpc/rpc_server.go @@ -5,31 +5,152 @@ import ( "errors" "fmt" "io" + "log" "reflect" + "sync" + "sync/atomic" + "time" ) type JSONRPCServer struct { writer io.Writer handlers map[string]*RPCHandler + nextId atomic.Int64 + + responseChannelsMutex sync.Mutex + responseChannels map[int64]chan JSONRPCResponse } func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { return &JSONRPCServer{ - writer: writer, - handlers: handlers, + writer: writer, + handlers: handlers, + responseChannels: make(map[int64]chan JSONRPCResponse), + nextId: atomic.Int64{}, } } +func (s *JSONRPCServer) 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 *JSONRPCServer) HandleMessage(data []byte) error { - var request JSONRPCRequest - err := json.Unmarshal(data, &request) + // 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: map[string]interface{}{ - "code": -32700, - "message": "Parse error", + 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, } @@ -41,9 +162,9 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error { if !ok { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32601, - "message": "Method not found", + Error: &JSONRPCResponseError{ + Code: -32601, + Message: "Method not found", }, ID: request.ID, } @@ -54,10 +175,10 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error { if err != nil { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32603, - "message": "Internal error", - "data": err.Error(), + Error: &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: err.Error(), }, ID: request.ID, } diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 30f8a2c..ac4f956 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -8,10 +8,16 @@ type JSONRPCRequest struct { } type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result interface{} `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` - ID interface{} `json:"id"` + 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 { diff --git a/internal/plugin/install.go b/internal/plugin/install.go index e5bf395..89319a8 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -3,7 +3,6 @@ package plugin import ( "fmt" "log" - "net" "os" "os/exec" "path" @@ -22,7 +21,7 @@ type PluginInstall struct { manifest *PluginManifest runningVersion *string processManager *ProcessManager - rpcListener net.Listener + rpcServer *PluginRpcServer } func (p *PluginInstall) GetManifest() (*PluginManifest, error) { @@ -54,13 +53,24 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { Enabled: p.Enabled, } - status.Status = "stopped" - if p.processManager != nil { - status.Status = "running" - if p.processManager.LastError != nil { - status.Status = "errored" - status.Error = p.processManager.LastError.Error() + if p.rpcServer != nil && p.rpcServer.status.Status != "disconnected" { + log.Printf("Status from RPC: %v", p.rpcServer.status) + 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 = "errored" + status.Message = p.processManager.LastError.Error() + } + } + log.Printf("Status from process manager: %v", status.Status) } return &status, nil @@ -94,8 +104,10 @@ func (p *PluginInstall) ReconcileSubprocess() error { p.processManager.Disable() p.processManager = nil p.runningVersion = nil - p.rpcListener.Close() - p.rpcListener = nil + err = p.rpcServer.Stop() + if err != nil { + return fmt.Errorf("failed to stop rpc server: %v", err) + } } if versionShouldBeRunning == "" { @@ -103,25 +115,22 @@ func (p *PluginInstall) ReconcileSubprocess() error { } workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name) - socketPath := path.Join(workingDir, "plugin.sock") - - os.Remove(socketPath) err = os.MkdirAll(workingDir, 0755) if err != nil { return fmt.Errorf("failed to create working directory: %v", err) } - listener, err := net.Listen("unix", socketPath) + p.rpcServer = NewPluginRpcServer(p, workingDir) + err = p.rpcServer.Start() if err != nil { - return fmt.Errorf("failed to listen on socket: %v", err) + return fmt.Errorf("failed to start rpc server: %v", err) } - p.rpcListener = listener p.processManager = NewProcessManager(func() *exec.Cmd { cmd := exec.Command(manifest.BinaryPath) cmd.Dir = p.GetExtractedFolder() cmd.Env = append(cmd.Env, - "JETKVM_PLUGIN_SOCK="+socketPath, + "JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(), "JETKVM_PLUGIN_WORKING_DIR="+workingDir, ) cmd.Stdout = os.Stdout @@ -147,8 +156,7 @@ func (p *PluginInstall) Shutdown() { p.runningVersion = nil } - if p.rpcListener != nil { - p.rpcListener.Close() - p.rpcListener = nil + if p.rpcServer != nil { + p.rpcServer.Stop() } } diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go new file mode 100644 index 0000000..9606dd6 --- /dev/null +++ b/internal/plugin/rpc.go @@ -0,0 +1,166 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "kvm/internal/jsonrpc" + "log" + "net" + "os" + "path" + "time" +) + +type PluginRpcStatus struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +var ( + PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""} + PluginRpcStatusLoading = PluginRpcStatus{"loading", ""} + PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""} + PluginRpcStatusRunning = PluginRpcStatus{"running", ""} + PluginRpcStatusError = PluginRpcStatus{"error", ""} +) + +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.NewJSONRPCServer(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 { + log.Printf("Failed to read message: %v", err) + if errors.Is(err, net.ErrClosed) { + s.status = PluginRpcStatusDisconnected + } else { + 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.JSONRPCServer) { + // log.Printf("Plugin rpc server started. Getting supported methods...") + // supportedMethodsResponse, err := rpcserver.Request("getPluginSupportedMethods", map[string]interface{}{}) + // 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).Error() + // } + + // if supportedMethodsResponse.Error != nil { + // log.Printf("Failed to get supported methods: %v", supportedMethodsResponse.Error) + // s.status = PluginRpcStatusError + // s.status.Message = fmt.Errorf("error getting supported methods: %v", supportedMethodsResponse.Error).Error() + // } + + // log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.Result) + + ticker := time.NewTicker(1 * time.Second) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + var statusResponse PluginRpcStatus + err := rpcserver.Request("getPluginStatus", map[string]interface{}{}, &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 + } + } +} diff --git a/internal/plugin/type.go b/internal/plugin/type.go index 0e6988c..de1001a 100644 --- a/internal/plugin/type.go +++ b/internal/plugin/type.go @@ -14,5 +14,5 @@ type PluginStatus struct { PluginManifest Enabled bool `json:"enabled"` Status string `json:"status"` - Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index c5808f1..c5ac794 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -1,13 +1,15 @@ import { PluginStatus } from "@/hooks/stores"; import Modal from "@components/Modal"; import AutoHeight from "@components/AutoHeight"; -import { GridCard } from "@components/Card"; +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, @@ -42,7 +44,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op return; } } - + setLoading(true); send("pluginUpdateConfig", { name: plugin.name, enabled }, resp => { if ("error" in resp) { @@ -77,16 +79,28 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
- JetKVM Logo - JetKVM Logo +
+
+ JetKVM Logo + JetKVM Logo +
+
+ {plugin && <> +

+ {plugin.status} +

+ + } +
+
@@ -104,6 +118,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
+
+
{error &&

{error}

} + {plugin?.message && ( + <> +

+ Plugin message: +

+ + {plugin.message} + + + )}

TODO: Plugin configuration goes here

+
+
-
- +
+ ) } diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index 96af34a..9be368c 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -2,24 +2,9 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; -import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; import PluginConfigureModal from "@components/PluginConfigureDialog"; - -function PluginListStatusIcon({ 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 === "errored") { - classNames = "bg-red-500 border-red-600"; - } - - return ( -
-
-
- ) -} +import { PluginStatusIcon } from "./PluginStatusIcon"; export default function PluginList() { const [send] = useJsonRpc(); @@ -45,20 +30,21 @@ export default function PluginList() { 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") return; + if (sidebarView !== "system" && !pluginConfigureModalOpen) return; updatePlugins(); const updateInterval = setInterval(() => { updatePlugins(); }, 10_000); return () => clearInterval(updateInterval); - }, [updatePlugins, sidebarView]) + }, [updatePlugins, sidebarView, pluginConfigureModalOpen]) return ( <> @@ -68,7 +54,7 @@ export default function PluginList() { {plugins.length === 0 &&
  • No plugins installed
  • } {plugins.map(plugin => (
  • - +

    {plugin.name}

    @@ -99,7 +85,7 @@ export default function PluginList() { updatePlugins(); } }} - plugin={configuringPlugin} + plugin={plugins.find(p => p.name == configuringPlugin?.name) ?? null} />

    diff --git a/ui/src/components/PluginStatusIcon.tsx b/ui/src/components/PluginStatusIcon.tsx new file mode 100644 index 0000000..721ec43 --- /dev/null +++ b/ui/src/components/PluginStatusIcon.tsx @@ -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 === "errored") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
    +
    +
    + ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index cdb132a..62fc3a8 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -539,8 +539,8 @@ export interface PluginManifest { export interface PluginStatus extends PluginManifest { enabled: boolean; - status: "stopped" | "running" | "errored"; - error?: string; + status: "stopped" | "running" | "loading" | "pending-configuration" | "errored"; + message?: string; } interface PluginState { From 2428c15f88e6cb6fdbfedc4ec30a29ecdf44ceb9 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:11:51 +0000 Subject: [PATCH 18/24] Handle error conditions better and detect support methods automatically --- internal/plugin/install.go | 8 +++-- internal/plugin/rpc.go | 38 +++++++++++++-------- ui/src/components/PluginConfigureDialog.tsx | 2 +- ui/src/components/PluginStatusIcon.tsx | 2 +- ui/src/hooks/stores.ts | 2 +- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/internal/plugin/install.go b/internal/plugin/install.go index 89319a8..0f0bf01 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -53,8 +53,10 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { Enabled: p.Enabled, } - if p.rpcServer != nil && p.rpcServer.status.Status != "disconnected" { - log.Printf("Status from RPC: %v", p.rpcServer.status) + // 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 @@ -66,7 +68,7 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { if p.processManager != nil { status.Status = "running" if p.processManager.LastError != nil { - status.Status = "errored" + status.Status = "error" status.Message = p.processManager.LastError.Error() } } diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go index 9606dd6..ff31472 100644 --- a/internal/plugin/rpc.go +++ b/internal/plugin/rpc.go @@ -9,6 +9,7 @@ import ( "net" "os" "path" + "slices" "time" ) @@ -19,12 +20,17 @@ type PluginRpcStatus struct { 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 @@ -103,10 +109,10 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { // 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 { - log.Printf("Failed to read message: %v", err) 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() } @@ -124,21 +130,23 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { } func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) { - // log.Printf("Plugin rpc server started. Getting supported methods...") - // supportedMethodsResponse, err := rpcserver.Request("getPluginSupportedMethods", map[string]interface{}{}) - // 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).Error() - // } + s.status = PluginRpcStatusUnknown - // if supportedMethodsResponse.Error != nil { - // log.Printf("Failed to get supported methods: %v", supportedMethodsResponse.Error) - // s.status = PluginRpcStatusError - // s.status.Message = fmt.Errorf("error getting supported methods: %v", supportedMethodsResponse.Error).Error() - // } + 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.Result) + 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 { @@ -147,7 +155,7 @@ func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrp return case <-ticker.C: var statusResponse PluginRpcStatus - err := rpcserver.Request("getPluginStatus", map[string]interface{}{}, &statusResponse) + 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) { diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index c5ac794..68a1710 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -134,7 +134,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op

    {plugin.message} diff --git a/ui/src/components/PluginStatusIcon.tsx b/ui/src/components/PluginStatusIcon.tsx index 721ec43..074751f 100644 --- a/ui/src/components/PluginStatusIcon.tsx +++ b/ui/src/components/PluginStatusIcon.tsx @@ -7,7 +7,7 @@ export function PluginStatusIcon({ plugin }: { plugin: PluginStatus; }) { 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 === "errored") { + } else if (plugin.enabled && plugin.status === "error") { classNames = "bg-red-500 border-red-600"; } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 62fc3a8..cb58760 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -539,7 +539,7 @@ export interface PluginManifest { export interface PluginStatus extends PluginManifest { enabled: boolean; - status: "stopped" | "running" | "loading" | "pending-configuration" | "errored"; + status: "stopped" | "running" | "loading" | "pending-configuration" | "error"; message?: string; } From 2e24916331cb216978ba03f692136a4576493f74 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:22:05 +0000 Subject: [PATCH 19/24] Change wording from TODO to coming soon --- ui/src/components/PluginConfigureDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 68a1710..7eab951 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -141,7 +141,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op )}

    - TODO: Plugin configuration goes here + Plugin configuration coming soon

    From 16064aa876050f7aaa63129710e3082fd8a93820 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:56:29 +0000 Subject: [PATCH 20/24] Better handle install and re-install lifecycle. Also display all the juicy bits about the plugin --- internal/plugin/install.go | 17 +++--- internal/plugin/plugin.go | 5 +- ui/src/components/PluginConfigureDialog.tsx | 58 +++++++++++++++++---- ui/src/components/PluginList.tsx | 4 +- ui/src/components/UploadPluginDialog.tsx | 9 +++- ui/src/hooks/stores.ts | 4 +- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/internal/plugin/install.go b/internal/plugin/install.go index 0f0bf01..dedf329 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -19,7 +19,7 @@ type PluginInstall struct { ExtractedVersions map[string]string `json:"extracted_versions"` manifest *PluginManifest - runningVersion *string + runningVersion string processManager *ProcessManager rpcServer *PluginRpcServer } @@ -84,10 +84,7 @@ func (p *PluginInstall) ReconcileSubprocess() error { return fmt.Errorf("failed to get plugin manifest: %v", err) } - versionRunning := "" - if p.runningVersion != nil { - versionRunning = *p.runningVersion - } + versionRunning := p.runningVersion versionShouldBeRunning := p.Version if !p.Enabled { @@ -105,7 +102,7 @@ func (p *PluginInstall) ReconcileSubprocess() error { log.Printf("Stopping plugin %s running version %s", manifest.Name, versionRunning) p.processManager.Disable() p.processManager = nil - p.runningVersion = nil + p.runningVersion = "" err = p.rpcServer.Stop() if err != nil { return fmt.Errorf("failed to stop rpc server: %v", err) @@ -146,7 +143,11 @@ func (p *PluginInstall) ReconcileSubprocess() error { }) p.processManager.StartMonitor() p.processManager.Enable() - p.runningVersion = &p.Version + 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 } @@ -155,7 +156,7 @@ func (p *PluginInstall) Shutdown() { if p.processManager != nil { p.processManager.Disable() p.processManager = nil - p.runningVersion = nil + p.runningVersion = "" } if p.rpcServer != nil { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 5aa0ccb..e72acdf 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -120,7 +120,6 @@ func RpcPluginExtract(filename string) (*PluginManifest, error) { } func RpcPluginInstall(name string, version string) error { - // TODO: find the plugin version in the plugins.json file pluginInstall, ok := pluginDatabase.Plugins[name] if !ok { return fmt.Errorf("plugin not found: %s", name) @@ -136,8 +135,6 @@ func RpcPluginInstall(name string, version string) error { return fmt.Errorf("plugin version not found: %s", version) } - // TODO: If there is a running plugin with the same name, stop it and start the new version - pluginInstall.Version = version pluginInstall.Enabled = true pluginDatabase.Plugins[name] = pluginInstall @@ -151,7 +148,7 @@ func RpcPluginInstall(name string, version string) error { return fmt.Errorf("failed to start plugin %s: %v", name, err) } - // TODO: Determine if the old version should be removed + // TODO: Determine if the old extract should be removed return nil } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 7eab951..cc1762e 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -1,4 +1,4 @@ -import { PluginStatus } from "@/hooks/stores"; +import { PluginStatus, usePluginStore } from "@/hooks/stores"; import Modal from "@components/Modal"; import AutoHeight from "@components/AutoHeight"; import Card, { GridCard } from "@components/Card"; @@ -33,6 +33,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const {setIsPluginUploadModalOpen} = usePluginStore(); + useEffect(() => { setLoading(false); }, [plugin]) @@ -73,6 +75,11 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op }); }, [send, plugin, setOpen]) + const uploadPlugin = useCallback(() => { + setOpen(false); + setIsPluginUploadModalOpen(true); + }, [setIsPluginUploadModalOpen, setOpen]) + return (
    @@ -118,6 +125,30 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
    +
    + + Name + + {plugin?.name} + + + Active Version + + {plugin?.version} + + + Description + + {plugin?.description} + + + Homepage + + + {plugin?.homepage} + +
    +
    {error}

    } {plugin?.message && ( <> -

    - Plugin message: -

    - - {plugin.message} - +

    + Plugin message: +

    + + {plugin.message} + )}

    @@ -154,6 +185,13 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op }} >

    +