mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 09:59:15 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ff328db8 | ||
|
|
ff45b46c49 |
@@ -35,7 +35,14 @@ then
|
||||
if [ -e /dev/mmcblk0p3 ]
|
||||
then
|
||||
mkdir -p /data
|
||||
mount /dev/mmcblk0p3 /data
|
||||
# The partition is the virtual disk. While it is shared as a writable
|
||||
# disk the host is the only writer, so mount it read-only here.
|
||||
if [ -e /boot/usb.disk0 ] && [ ! -e /boot/usb.disk0.ro ] && [ ! -s /boot/usb.disk0 ]
|
||||
then
|
||||
mount -o ro /dev/mmcblk0p3 /data
|
||||
else
|
||||
mount /dev/mmcblk0p3 /data
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "OK"
|
||||
|
||||
@@ -134,11 +134,15 @@ start_usb_dev(){
|
||||
mkdir functions/mass_storage.disk0
|
||||
ln -s functions/mass_storage.disk0 configs/c.1/
|
||||
echo 1 > functions/mass_storage.disk0/lun.0/removable
|
||||
# The LUN flags survive a restart of this script, so they are always set:
|
||||
# a mounted image leaves them read-only and in CD-ROM mode.
|
||||
if [ -e /boot/usb.disk0.ro ]
|
||||
then
|
||||
echo 1 > functions/mass_storage.disk0/lun.0/ro
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
|
||||
else
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/ro
|
||||
fi
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
|
||||
echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string
|
||||
disk=$(cat /boot/usb.disk0)
|
||||
if [ -z "${disk}" ]
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type downloadStatus string
|
||||
@@ -33,11 +34,24 @@ const (
|
||||
downloadStatusSuccess downloadStatus = "success"
|
||||
downloadStatusFailed downloadStatus = "failed"
|
||||
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
|
||||
|
||||
imageDirectory = "/data"
|
||||
|
||||
// ISO 9660 and UDF volume descriptors live in sector 16 of the image.
|
||||
volumeDescriptorOffset = 0x8001
|
||||
volumeDescriptorSize = 5
|
||||
// Only the descriptors are read before the image is stored, so an unusable
|
||||
// image is rejected without transferring all of it.
|
||||
imageHeaderSize = volumeDescriptorOffset + volumeDescriptorSize
|
||||
// Keep /data from filling up completely: the exFAT metadata and the USB
|
||||
// mass-storage export still need room while the image is written.
|
||||
imageFreeReserve = 128 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
errDownloadInProgress = errors.New("download in progress")
|
||||
errSHA256Mismatch = errors.New("sha256 mismatch")
|
||||
errUnsupportedImage = errors.New("unsupported image format: expected an ISO 9660/UDF image or a raw disk image")
|
||||
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
)
|
||||
|
||||
@@ -164,24 +178,73 @@ func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
|
||||
}
|
||||
|
||||
func isISO9660(path string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ISO-9660 magic "CD001" at offset 32769.
|
||||
if _, err = f.Seek(0x8001, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
// isSupportedImage reports whether the uploaded image can be exposed through
|
||||
// the USB mass-storage gadget. ISO 9660 images and UDF bridges, which is what
|
||||
// bootable installer ISOs are normally built as, carry "CD001" in the volume
|
||||
// descriptor sector, while UDF-only images carry "BEA01" there.
|
||||
func isSupportedImage(head []byte) bool {
|
||||
if len(head) >= volumeDescriptorOffset+volumeDescriptorSize {
|
||||
switch string(head[volumeDescriptorOffset : volumeDescriptorOffset+volumeDescriptorSize]) {
|
||||
case "CD001", "BEA01":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
if _, err = io.ReadFull(f, buf); err != nil {
|
||||
return false, err
|
||||
return looksLikeDiskImage(head)
|
||||
}
|
||||
|
||||
// looksLikeDiskImage reports whether the image starts with a partition table or
|
||||
// a filesystem that a target machine can mount on its own, which covers raw
|
||||
// disk images such as macOS installers converted from a DMG.
|
||||
func looksLikeDiskImage(head []byte) bool {
|
||||
if len(head) < 0x200 {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(buf) == "CD001", nil
|
||||
if len(head) >= 0x208 && string(head[0x200:0x208]) == "EFI PART" { // GPT header
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x24 && string(head[0x20:0x24]) == "NXSB" { // APFS container superblock
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x402 {
|
||||
switch string(head[0x400:0x402]) { // HFS+/HFS volume header
|
||||
case "H+", "BD":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MBR partition table. FAT, exFAT and NTFS volume images carry the same
|
||||
// signature in their boot sector.
|
||||
return head[0x1FE] == 0x55 && head[0x1FF] == 0xAA
|
||||
}
|
||||
|
||||
// ensureImageSpace rejects an upload that cannot fit into dir, keeping a small
|
||||
// reserve for filesystem metadata and for the USB mass-storage export.
|
||||
func ensureImageSpace(dir string, contentLength int64) error {
|
||||
if contentLength <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(dir, &stat); err != nil {
|
||||
return fmt.Errorf("cannot read free space of %s: %w", dir, err)
|
||||
}
|
||||
|
||||
available := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
payload := uint64(contentLength)
|
||||
if payload > available || available-payload < imageFreeReserve {
|
||||
return fmt.Errorf(
|
||||
"not enough space in %s: need %d MiB including reserve, %d MiB available",
|
||||
dir,
|
||||
(payload+imageFreeReserve)>>20,
|
||||
available>>20,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
@@ -210,6 +273,13 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject an upload that cannot fit before any of it is received.
|
||||
if err := ensureImageSpace(imageDirectory, c.Request.ContentLength); err != nil {
|
||||
log.Warnf("reject image upload: %s", err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
done, err := s.beginDownload("", nil)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
@@ -244,16 +314,17 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
|
||||
filename := part.FileName()
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
_ = part.Close()
|
||||
log.Warnf("reject uploaded image %q: %s", filename, err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
s.setDownloadFile(done, filename)
|
||||
|
||||
out, err := os.CreateTemp("/data", ".nanokvm-upload-*")
|
||||
// The rejection paths below return without draining the request body: the
|
||||
// client stops uploading as soon as the response arrives.
|
||||
out, err := os.CreateTemp(imageDirectory, ".nanokvm-upload-*")
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
log.Error("cannot create temporary file")
|
||||
log.Errorf("cannot create temporary file in %s: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -1, "cannot create temporary file")
|
||||
return
|
||||
}
|
||||
@@ -264,12 +335,30 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
})
|
||||
_, copyErr := io.Copy(lw, part)
|
||||
lw.stopTicker()
|
||||
partCloseErr := part.Close()
|
||||
outCloseErr := out.Close()
|
||||
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
|
||||
log.Error("write failed")
|
||||
defer lw.stopTicker()
|
||||
|
||||
// Check the volume descriptors while streaming, so an unusable image is
|
||||
// rejected in seconds instead of after the whole upload.
|
||||
head := make([]byte, imageHeaderSize)
|
||||
headSize, readErr := io.ReadFull(part, head)
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||
log.Errorf("read uploaded image %q failed: %s", filename, readErr)
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
if !isSupportedImage(head[:headSize]) {
|
||||
log.Warnf("reject uploaded image %q: %s", filename, errUnsupportedImage)
|
||||
rsp.ErrRsp(c, -1, errUnsupportedImage.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, copyErr := lw.Write(head[:headSize])
|
||||
if copyErr == nil {
|
||||
_, copyErr = io.Copy(lw, part)
|
||||
}
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, closeErr))
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
@@ -280,13 +369,7 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := isISO9660(tempPath)
|
||||
if err != nil || !valid {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
return
|
||||
}
|
||||
|
||||
outPath := filepath.Join("/data", filename)
|
||||
outPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, "cannot install uploaded image")
|
||||
return
|
||||
@@ -422,7 +505,7 @@ func (s *Service) downloadRemoteImage(
|
||||
return fmt.Errorf("download request returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("/data", ".nanokvm-download-*")
|
||||
tempFile, err := os.CreateTemp(imageDirectory, ".nanokvm-download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary image failed: %w", err)
|
||||
}
|
||||
@@ -451,7 +534,7 @@ func (s *Service) downloadRemoteImage(
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
destPath := filepath.Join("/data", filename)
|
||||
destPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded image failed: %w", err)
|
||||
}
|
||||
|
||||
73
server/service/download/service_test.go
Normal file
73
server/service/download/service_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func headWithDescriptor(magic string) []byte {
|
||||
head := make([]byte, imageHeaderSize)
|
||||
copy(head[volumeDescriptorOffset:], magic)
|
||||
return head
|
||||
}
|
||||
|
||||
func TestIsSupportedImage(t *testing.T) {
|
||||
mbr := make([]byte, imageHeaderSize)
|
||||
mbr[0x1FE], mbr[0x1FF] = 0x55, 0xAA
|
||||
|
||||
gpt := make([]byte, imageHeaderSize)
|
||||
copy(gpt[0x200:], "EFI PART")
|
||||
|
||||
apfs := make([]byte, imageHeaderSize)
|
||||
copy(apfs[0x20:], "NXSB")
|
||||
|
||||
hfs := make([]byte, imageHeaderSize)
|
||||
copy(hfs[0x400:], "H+")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
head []byte
|
||||
want bool
|
||||
}{
|
||||
{"iso9660 image", headWithDescriptor("CD001"), true},
|
||||
{"udf bridge image", headWithDescriptor("CD001"), true},
|
||||
{"udf only image", headWithDescriptor("BEA01"), true},
|
||||
{"gpt disk image", gpt, true},
|
||||
{"apfs disk image", apfs, true},
|
||||
{"hfs+ disk image", hfs, true},
|
||||
{"mbr disk image", mbr, true},
|
||||
{"compressed dmg", make([]byte, imageHeaderSize), false},
|
||||
{"plain data", bytes.Repeat([]byte{0xA5}, imageHeaderSize), false},
|
||||
{"truncated image", headWithDescriptor("CD001")[:16], false},
|
||||
{"empty image", nil, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isSupportedImage(test.head); got != test.want {
|
||||
t.Fatalf("isSupportedImage(%s) = %v, want %v", test.name, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureImageSpace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ensureImageSpace(dir, 0); err != nil {
|
||||
t.Fatalf("an upload without a known size must not be rejected: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<20); err != nil {
|
||||
t.Fatalf("a small upload must fit: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<62); err == nil {
|
||||
t.Fatal("an upload larger than the filesystem must be rejected")
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(filepath.Join(dir, "missing"), 1<<20); err == nil {
|
||||
t.Fatal("a missing directory must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -8,9 +9,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/hid"
|
||||
@@ -23,6 +26,12 @@ const (
|
||||
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
|
||||
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
|
||||
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
|
||||
usbProduct = "/sys/kernel/config/usb_gadget/g0/strings/0x409/product"
|
||||
|
||||
usbProductName = "NanoKVM"
|
||||
// the SCSI inquiry product field holds 16 bytes, the USB product string 126
|
||||
inquiryProductSize = 16
|
||||
usbProductSize = 126
|
||||
)
|
||||
|
||||
func (s *Service) GetImages(c *gin.Context) {
|
||||
@@ -63,35 +72,49 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom and ro flag
|
||||
// The host owns the raw partition while the virtual disk serves it, so /data
|
||||
// has to be read-only here before the host can write it.
|
||||
if req.File == "" {
|
||||
if err := SetDataWritable(false); err != nil {
|
||||
log.Errorf("make %s read-only failed: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -2, "make /data read-only failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// set the LUN flags, unmounting first so no image is ever served writable
|
||||
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
|
||||
log.Errorf("unmount file failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "unmount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
// ro flag
|
||||
// set to 0 when unmount image
|
||||
// set to 1 when mount image, the host can only read the image
|
||||
roFlagValue := "0"
|
||||
if req.File != "" {
|
||||
roFlagValue = "1"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(roFlag, []byte(roFlagValue), 0o666); err != nil {
|
||||
log.Errorf("set ro flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set ro flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom flag
|
||||
// set to 0 when unmount image
|
||||
// set to 1 when mount image and the CD-ROM is enabled
|
||||
if req.File == "" || req.Cdrom {
|
||||
flag := "0"
|
||||
if req.File != "" && req.Cdrom {
|
||||
flag = "1"
|
||||
}
|
||||
cdromFlagValue := "0"
|
||||
if req.File != "" && req.Cdrom {
|
||||
cdromFlagValue = "1"
|
||||
}
|
||||
|
||||
// unmount
|
||||
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
|
||||
log.Errorf("unmount file failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "unmount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
// ro flag
|
||||
if err := os.WriteFile(roFlag, []byte(flag), 0o666); err != nil {
|
||||
log.Errorf("set ro flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set ro flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom flag
|
||||
if err := os.WriteFile(cdromFlag, []byte(flag), 0o666); err != nil {
|
||||
log.Errorf("set cdrom flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set cdrom flag failed")
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(cdromFlag, []byte(cdromFlagValue), 0o666); err != nil {
|
||||
log.Errorf("set cdrom flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set cdrom flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
inquiryVen := "NanoKVM"
|
||||
@@ -100,6 +123,17 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
if req.Cdrom {
|
||||
inquiryPrd = "USB CD/DVD-ROM"
|
||||
}
|
||||
|
||||
// the image name is what the host shows for the device and for the medium,
|
||||
// the SCSI inquiry is the medium and the USB product string the device itself
|
||||
productName := usbProductName
|
||||
if req.File != "" {
|
||||
if name := imageName(req.File); name != "" {
|
||||
inquiryPrd = cutName(name, inquiryProductSize)
|
||||
productName = cutName(name, usbProductSize)
|
||||
}
|
||||
}
|
||||
|
||||
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
|
||||
|
||||
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
|
||||
@@ -108,6 +142,10 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(usbProduct, []byte(productName), 0o666); err != nil {
|
||||
log.Errorf("set usb product %s failed: %s", productName, err)
|
||||
}
|
||||
|
||||
// mount
|
||||
image := req.File
|
||||
if image == "" {
|
||||
@@ -120,6 +158,14 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// An image is served read-only, so the KVM writes /data itself again. The
|
||||
// host can not write the image either way.
|
||||
if req.File != "" {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", imageDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
@@ -163,17 +209,12 @@ func (s *Service) GetMountedImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
image, err := MountedImage()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
image := strings.ReplaceAll(string(content), "\n", "")
|
||||
if image == imageNone {
|
||||
image = ""
|
||||
}
|
||||
|
||||
data := &proto.GetMountedImageRsp{
|
||||
File: image,
|
||||
}
|
||||
@@ -181,6 +222,85 @@ func (s *Service) GetMountedImage(c *gin.Context) {
|
||||
rsp.OkRspWithData(c, data)
|
||||
}
|
||||
|
||||
// MountedImage returns the mounted image file, empty if no image is mounted
|
||||
func MountedImage() (string, error) {
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
image := strings.ReplaceAll(string(content), "\n", "")
|
||||
if image == imageNone {
|
||||
image = ""
|
||||
}
|
||||
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// imageName returns the file name of an image without the extension
|
||||
func imageName(file string) string {
|
||||
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
}
|
||||
|
||||
// cutName cuts a name to size bytes without splitting a character
|
||||
func cutName(name string, size int) string {
|
||||
if len(name) <= size {
|
||||
return name
|
||||
}
|
||||
|
||||
name = name[:size]
|
||||
for !utf8.ValidString(name) {
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// SetDataWritable remounts /data read-write or read-only. Only one side may
|
||||
// write the partition at a time: while the virtual disk shares it with the host
|
||||
// as a writable disk, /data is read-only here, and it becomes writable again
|
||||
// once the share is released or an image is served read-only instead.
|
||||
func SetDataWritable(writable bool) error {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &stat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
|
||||
return nil
|
||||
}
|
||||
|
||||
option := "rw"
|
||||
if !writable {
|
||||
option = "ro"
|
||||
}
|
||||
|
||||
if err := exec.Command("mount", "-o", "remount,"+option, imageDirectory).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// never hand a writable /data to the host
|
||||
var check unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &check); err != nil {
|
||||
return err
|
||||
}
|
||||
if (check.Flags&unix.ST_RDONLY == 0) != writable {
|
||||
return errors.New(imageDirectory + " mount did not change")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataDiskAttached reports whether the mass storage device serves the whole /data partition to the host
|
||||
func DataDiskAttached() bool {
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(string(content), "\n", "") == imageNone
|
||||
}
|
||||
|
||||
func (s *Service) GetCdRom(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
|
||||
42
server/service/storage/image_test.go
Normal file
42
server/service/storage/image_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageName(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
want string
|
||||
}{
|
||||
{"/data/ubuntu.iso", "ubuntu"},
|
||||
{"/data/win11.img", "win11"},
|
||||
{"/data/a.b.c.iso", "a.b.c"},
|
||||
{"/data/no-extension", "no-extension"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := imageName(tt.file); got != tt.want {
|
||||
t.Errorf("imageName(%q) = %q, want %q", tt.file, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
want string
|
||||
}{
|
||||
{"ubuntu", 16, "ubuntu"},
|
||||
{"Install_macOS_27_Golden_Gate", 16, "Install_macOS_27"},
|
||||
{"Install_macOS_27_Golden_Gate", 126, "Install_macOS_27_Golden_Gate"},
|
||||
// 16 bytes is 5 three byte runes and 1 spare byte
|
||||
{"中文安装镜像文件系统", 16, "中文安装镜"},
|
||||
{"中文安装镜像文件系统", 126, "中文安装镜像文件系统"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := cutName(tt.name, tt.size); got != tt.want {
|
||||
t.Errorf("cutName(%q, %d) = %q, want %q", tt.name, tt.size, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,11 +50,10 @@ func (s *Service) GetVirtualDevice(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
network, _ := isDeviceExist(virtualNetwork)
|
||||
disk, _ := isDeviceExist(virtualDisk)
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVirtualDeviceRsp{
|
||||
Network: network,
|
||||
Disk: disk,
|
||||
Disk: diskAttached(),
|
||||
})
|
||||
log.Debugf("get virtual device success")
|
||||
}
|
||||
@@ -83,11 +83,18 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
case "disk":
|
||||
device = virtualDisk
|
||||
|
||||
exist, _ := isDeviceExist(device)
|
||||
if !exist {
|
||||
commands = mountDiskCommands
|
||||
} else {
|
||||
if diskAttached() {
|
||||
commands = unmountDiskCommands
|
||||
} else {
|
||||
// the host writes the partition from now on. A mounted image is released by
|
||||
// the restarted gadget, which attaches the raw partition again.
|
||||
if err := storage.SetDataWritable(false); err != nil {
|
||||
log.Errorf("make /data read-only failed: %s", err)
|
||||
rsp.ErrRsp(c, -3, "make /data read-only failed")
|
||||
return
|
||||
}
|
||||
|
||||
commands = mountDiskCommands
|
||||
}
|
||||
default:
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
@@ -110,6 +117,13 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// the disk is gone from the host, the KVM writes /data itself again
|
||||
if device == virtualDisk && !diskAttached() {
|
||||
if err := storage.SetDataWritable(true); err != nil {
|
||||
log.Errorf("make /data writable failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
on, _ := isDeviceExist(device)
|
||||
rsp.OkRspWithData(c, &proto.UpdateVirtualDeviceRsp{
|
||||
On: on,
|
||||
@@ -118,6 +132,14 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
log.Debugf("update virtual device %s success", req.Device)
|
||||
}
|
||||
|
||||
// diskAttached reports whether the virtual disk is enabled and the /data
|
||||
// partition is currently shared with the host. A virtual disk that serves an
|
||||
// image, or nothing at all, counts as disabled.
|
||||
func diskAttached() bool {
|
||||
enabled, _ := isDeviceExist(virtualDisk)
|
||||
return enabled && storage.DataDiskAttached()
|
||||
}
|
||||
|
||||
func isDeviceExist(device string) (bool, error) {
|
||||
_, err := os.Stat(device)
|
||||
|
||||
|
||||
@@ -220,6 +220,12 @@ const ca = {
|
||||
mountMode: 'Mode de muntatge',
|
||||
mountFailed: 'Error en muntar',
|
||||
mountDesc: 'En alguns sistemes cal expulsar el disc virtual abans de muntar la imatge.',
|
||||
diskOnWarn:
|
||||
"Muntar la imatge desactiva el disc virtual, de manera que l'ordinador ja no pot accedir a /data. Voleu continuar?",
|
||||
diskOffWarn:
|
||||
"El disc virtual està desactivat i cal activar-lo per muntar la imatge. Els dispositius USB de l'ordinador es tornaran a enumerar. Voleu continuar?",
|
||||
diskShared:
|
||||
'El disc virtual està activat, així que /data és només de lectura aquí. Desactiveu-lo per suprimir o pujar imatges.',
|
||||
unmountFailed: "No s'ha pogut desmuntar",
|
||||
unmountDesc:
|
||||
"En alguns sistemes, cal expulsar manualment de l'amfitrió remot abans de desmuntar la imatge.",
|
||||
@@ -288,6 +294,8 @@ const ca = {
|
||||
input: 'Introdueix la URL de la imatge',
|
||||
ok: "D'acord",
|
||||
disabled: 'La partició /data és només lectura. No es pot descarregar la imatge.',
|
||||
diskShared: "Apagueu primer el disc virtual i després useu la descàrrega d'imatges.",
|
||||
diskOff: 'Apaga el disc virtual',
|
||||
uploadbox: 'Deixeu anar el fitxer aquí o feu clic per seleccionar-lo',
|
||||
inputfile: "Introduïu el fitxer d'imatge",
|
||||
NoISO: 'Cap ISO',
|
||||
@@ -446,6 +454,11 @@ const ca = {
|
||||
hidOnlyDesc: "Deixeu d'emular dispositius virtuals, conservant només el control bàsic HID",
|
||||
disk: 'Disc virtual',
|
||||
diskDesc: 'Munta un disc U virtual al dispositiu remot',
|
||||
diskLocked: 'Desactivat mentre hi ha una imatge muntada (activar-lo cancel·la el muntatge)',
|
||||
diskCancelMount:
|
||||
"Activar el disc virtual cancel·la la imatge muntada i interromp una descàrrega o pujada d'imatge en curs. Voleu continuar?",
|
||||
diskBreakDownload:
|
||||
"Activar el disc virtual interromp la descàrrega d'imatges. Voleu continuar?",
|
||||
network: 'Xarxa virtual',
|
||||
networkDesc: 'Munta una targeta de xarxa virtual al dispositiu remot',
|
||||
reboot: 'Reinicia',
|
||||
|
||||
@@ -222,6 +222,12 @@ const cz = {
|
||||
mountFailed: 'Připojení se nezdařilo',
|
||||
mountDesc:
|
||||
'V některých systémech je nutné před připojením obrazu vysunout virtuální disk na vzdáleném hostiteli.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Odpojení se nezdařilo',
|
||||
unmountDesc:
|
||||
'Na některých systémech se musíte před odpojením obrazu ručně vysunout ze vzdáleného hostitele.',
|
||||
@@ -290,6 +296,8 @@ const cz = {
|
||||
input: 'Zadejte prosím vzdálený obrázek URL',
|
||||
ok: 'OK',
|
||||
disabled: 'Oddíl /data je RO, takže obrázek nelze stáhnout',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Přetáhněte soubor sem nebo kliknutím vyberte',
|
||||
inputfile: 'Zadejte soubor obrázku',
|
||||
NoISO: 'Žádné ISO',
|
||||
@@ -447,6 +455,10 @@ const cz = {
|
||||
hidOnlyDesc: 'Zastavit emulaci virtuálních zařízení a zachovat pouze základní ovládání HID',
|
||||
disk: 'Virtuální disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuální síť',
|
||||
networkDesc: 'Připojit virtuální síťovou kartu na vzdáleném hostiteli',
|
||||
reboot: 'Restartujte',
|
||||
|
||||
@@ -221,6 +221,12 @@ const da = {
|
||||
mountFailed: 'Montering af diskbillede mislykkedes',
|
||||
mountDesc:
|
||||
'På nogle systemer kan det være nødvendigt at skubbe den virtuelle disk ud på fjerncomputeren før du kan montere diskbilledet.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Afmontering mislykkedes',
|
||||
unmountDesc:
|
||||
'På nogle systemer skal du manuelt skubbe ud fra fjernværten, før du afmonterer billedet.',
|
||||
@@ -289,6 +295,8 @@ const da = {
|
||||
input: 'Indtast venligst et fjernbillede URL',
|
||||
ok: 'OK',
|
||||
disabled: '/data partitionen er RO, så vi kan ikke downloade billedet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Slip filen her, eller klik for at vælge',
|
||||
inputfile: 'Indtast venligst billedfilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
@@ -446,6 +454,10 @@ const da = {
|
||||
'Stop med at emulere virtuelle enheder, og behold kun grundlæggende HID kontrol',
|
||||
disk: 'Virtuel disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuelt netværk',
|
||||
networkDesc: 'Monter det virtuelle netværkskort på den eksterne vært',
|
||||
reboot: 'Genstart',
|
||||
|
||||
@@ -226,6 +226,12 @@ const de = {
|
||||
mountFailed: 'Einbinden fehlgeschlagen',
|
||||
mountDesc:
|
||||
'In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.',
|
||||
diskOnWarn:
|
||||
'Beim Einbinden des Images wird das virtuelle Laufwerk ausgeschaltet, sodass der Computer nicht mehr auf /data zugreifen kann. Fortfahren?',
|
||||
diskOffWarn:
|
||||
'Das virtuelle Laufwerk ist ausgeschaltet und muss zum Einbinden des Images aktiviert werden. Die USB-Geräte am Computer werden neu erkannt. Fortfahren?',
|
||||
diskShared:
|
||||
'Das virtuelle Laufwerk ist aktiv, daher ist /data hier schreibgeschützt. Deaktivieren Sie es, um Images zu löschen oder hochzuladen.',
|
||||
unmountFailed: 'Das Aufheben der Bereitstellung ist fehlgeschlagen',
|
||||
unmountDesc:
|
||||
'Auf einigen Systemen müssen Sie das Image manuell vom Remote-Host auswerfen, bevor Sie die Bereitstellung aufheben.',
|
||||
@@ -295,6 +301,9 @@ const de = {
|
||||
ok: 'Ok',
|
||||
disabled:
|
||||
'/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
|
||||
diskShared:
|
||||
'Schalten Sie zuerst das virtuelle Laufwerk aus und verwenden Sie dann den Image-Download.',
|
||||
diskOff: 'Virtuelles Laufwerk ausschalten',
|
||||
uploadbox: 'Datei hier ablegen oder klicken zum Auswählen',
|
||||
inputfile: 'Bitte geben Sie die Datei für das Systemabbild an',
|
||||
NoISO: 'Keine ISO',
|
||||
@@ -454,6 +463,11 @@ const de = {
|
||||
'Hören Sie auf, virtuelle Geräte zu emulieren, und behalten Sie nur die grundlegende HID-Steuerung bei',
|
||||
disk: 'Virtuelle Festplatte',
|
||||
diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host',
|
||||
diskLocked: 'Aus, solange ein Image eingebunden ist (Einschalten bricht die Einbindung ab)',
|
||||
diskCancelMount:
|
||||
'Das Aktivieren des virtuellen Laufwerks bricht das eingebundene Image ab und unterbricht einen laufenden Image-Download oder -Upload. Fortfahren?',
|
||||
diskBreakDownload:
|
||||
'Das Aktivieren des virtuellen Laufwerks unterbricht den Image-Download. Fortfahren?',
|
||||
network: 'Virtuelles Netzwerk',
|
||||
networkDesc: 'Binde die virtuelle Netzwerkkarte an den entfernten Host',
|
||||
reboot: 'Neustarten',
|
||||
|
||||
@@ -222,6 +222,12 @@ const en = {
|
||||
mountFailed: 'Mount failed',
|
||||
mountDesc:
|
||||
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Unmount failed',
|
||||
unmountDesc:
|
||||
'On some systems, you need to manually eject from the remote host before unmounting the image.',
|
||||
@@ -290,6 +296,8 @@ const en = {
|
||||
input: 'Please enter a remote image URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partition is RO, so we cannot download the image',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Drop file here or click to select',
|
||||
inputfile: 'Please enter the image File',
|
||||
NoISO: 'No ISO',
|
||||
@@ -445,6 +453,10 @@ const en = {
|
||||
hidOnlyDesc: 'Stop emulating virtual devices, retaining only basic HID control',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount SD card on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host',
|
||||
reboot: 'Reboot',
|
||||
|
||||
@@ -223,6 +223,12 @@ const es = {
|
||||
mountFailed: 'Fallo al montar',
|
||||
mountDesc:
|
||||
'En algunos sistemas, es necesario expulsar el disco virtual del host remoto antes de montar una imagen.',
|
||||
diskOnWarn:
|
||||
'Montar la imagen desactiva el disco virtual, por lo que el equipo ya no puede acceder a /data. ¿Continuar?',
|
||||
diskOffWarn:
|
||||
'El disco virtual está desactivado y hay que activarlo para montar la imagen. Los dispositivos USB del equipo se enumeran de nuevo. ¿Continuar?',
|
||||
diskShared:
|
||||
'El disco virtual está activado, así que /data es de solo lectura aquí. Desactívalo para eliminar o subir imágenes.',
|
||||
unmountFailed: 'Fallo al desmontar',
|
||||
unmountDesc:
|
||||
'En algunos sistemas, es necesario expulsar manualmente el disco virtual desde el host remoto antes de desmontar la imagen.',
|
||||
@@ -291,6 +297,8 @@ const es = {
|
||||
input: 'Por favor, introduce la URL de una imagen remota',
|
||||
ok: 'Aceptar',
|
||||
disabled: 'La partición /data es de sólo lectura, no se puede descargar la imagen',
|
||||
diskShared: 'Desactiva primero el disco virtual y luego usa la descarga de imágenes.',
|
||||
diskOff: 'Desactivar el disco virtual',
|
||||
uploadbox: 'Suelte el archivo aquí o haga clic para seleccionar',
|
||||
inputfile: 'Por favor ingrese el archivo de imagen',
|
||||
NoISO: 'Sin ISO',
|
||||
@@ -450,6 +458,11 @@ const es = {
|
||||
'Dejar de emular dispositivos virtuales y conservar solo el control básico HID',
|
||||
disk: 'Disco Virtual',
|
||||
diskDesc: 'Montar disco virtual en el host remoto',
|
||||
diskLocked: 'Desactivado mientras hay una imagen montada (activarlo cancela el montaje)',
|
||||
diskCancelMount:
|
||||
'Activar el disco virtual cancela la imagen montada e interrumpe una descarga o subida de imagen en curso. ¿Continuar?',
|
||||
diskBreakDownload:
|
||||
'Activar el disco virtual interrumpe la descarga de imágenes. ¿Continuar?',
|
||||
network: 'Red Virtual',
|
||||
networkDesc: 'Montar tarjeta de red virtual en el host remoto',
|
||||
reboot: 'Reiniciar',
|
||||
|
||||
@@ -225,6 +225,12 @@ const fr = {
|
||||
mountFailed: "Échec du montage de l'image.",
|
||||
mountDesc:
|
||||
"Dans certains systèmes, il est nécessaire de déséjecter le disque virtuel sur l'hôte distant avant de monter l'image.",
|
||||
diskOnWarn:
|
||||
"Monter l'image désactive le disque virtuel : l'ordinateur ne peut plus accéder à /data. Continuer ?",
|
||||
diskOffWarn:
|
||||
"Le disque virtuel est désactivé et doit être activé pour monter l'image. Les périphériques USB de l'ordinateur sont réénumérés. Continuer ?",
|
||||
diskShared:
|
||||
'Le disque virtuel est activé, donc /data est en lecture seule ici. Désactivez-le pour supprimer ou envoyer des images.',
|
||||
unmountFailed: 'Échec du démontage',
|
||||
unmountDesc:
|
||||
"Sur certains systèmes, vous devez l'éjecter manuellement de l'hôte distant avant de démonter l'image.",
|
||||
@@ -293,6 +299,8 @@ const fr = {
|
||||
input: 'Veuillez entrer l’URL d’une image distante',
|
||||
ok: 'Ok',
|
||||
disabled: 'La partition /data est en lecture seule, impossible de télécharger l’image',
|
||||
diskShared: "Désactivez d'abord le disque virtuel, puis utilisez le téléchargement d'images.",
|
||||
diskOff: 'Désactiver le disque virtuel',
|
||||
uploadbox: 'Déposez le fichier ici ou cliquez pour sélectionner',
|
||||
inputfile: 'Veuillez saisir le fichier image',
|
||||
NoISO: 'Aucun ISO',
|
||||
@@ -452,6 +460,11 @@ const fr = {
|
||||
"Arrêtez d'émuler des périphériques virtuels, en ne conservant que le contrôle de base HID",
|
||||
disk: 'Disque virtuel',
|
||||
diskDesc: "Monter le disque virtuel U sur l'hôte distant",
|
||||
diskLocked: "Désactivé tant qu'une image est montée (l'activer annule le montage)",
|
||||
diskCancelMount:
|
||||
"Activer le disque virtuel annule l'image montée et interrompt un téléchargement ou un envoi d'image en cours. Continuer ?",
|
||||
diskBreakDownload:
|
||||
"Activer le disque virtuel interrompt le téléchargement d'images. Continuer ?",
|
||||
network: 'Réseau virtuel',
|
||||
networkDesc: "Monter la carte réseau virtuelle sur l'hôte distant",
|
||||
reboot: 'Redémarrer',
|
||||
|
||||
@@ -224,6 +224,12 @@ const hu = {
|
||||
mountFailed: 'Csatlakoztatás sikertelen',
|
||||
mountDesc:
|
||||
'Egyes rendszerekben szükséges lehet a virtuális lemez eltávolítása a távoli gépen, mielőtt a képet csatlakoztatja.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'A leválasztás nem sikerült',
|
||||
unmountDesc:
|
||||
'Egyes rendszereken manuálisan kell kiadnia a távoli gazdagépről a kép leválasztása előtt.',
|
||||
@@ -292,6 +298,8 @@ const hu = {
|
||||
input: 'Adjon meg egy távoli képet URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partíció RO, ezért nem tudjuk letölteni a képet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Dobja ide a fájlt, vagy kattintson a kiválasztáshoz',
|
||||
inputfile: 'Kérjük, írja be a képfájlt',
|
||||
NoISO: 'Nincs ISO',
|
||||
@@ -450,6 +458,10 @@ const hu = {
|
||||
'A virtuális eszközök emulálásának leállítása, csak az alapvető HID vezérlés megtartásával',
|
||||
disk: 'Virtuális lemez',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuális hálózat',
|
||||
networkDesc: 'Virtuális hálózati kártya csatlakoztatása a távoli gazdagépen',
|
||||
reboot: 'Újraindítás',
|
||||
|
||||
@@ -222,6 +222,12 @@ const id = {
|
||||
mountFailed: 'Pemasangan Gagal',
|
||||
mountDesc:
|
||||
'Di beberapa sistem, perlu mengeluarkan disk virtual pada host jarak jauh sebelum memasang gambar.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Pelepasan gagal',
|
||||
unmountDesc:
|
||||
'Pada beberapa sistem, Anda perlu mengeluarkan secara manual dari host jarak jauh sebelum melepas gambar.',
|
||||
@@ -290,6 +296,8 @@ const id = {
|
||||
input: 'Silakan masukkan gambar jarak jauh URL',
|
||||
ok: 'Ok',
|
||||
disabled: 'Partisi /data adalah RO, jadi kami tidak dapat mengunduh gambarnya',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Letakkan file di sini atau klik untuk memilih',
|
||||
inputfile: 'Silakan masukkan File gambar',
|
||||
NoISO: 'Tidak ada ISO',
|
||||
@@ -447,6 +455,10 @@ const id = {
|
||||
hidOnlyDesc: 'Berhenti meniru perangkat virtual, hanya mempertahankan kontrol dasar HID',
|
||||
disk: 'Disk virtual',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Jaringan virtual',
|
||||
networkDesc: 'Pasang kartu jaringan virtual pada host jarak jauh',
|
||||
reboot: 'Mulai ulang',
|
||||
|
||||
@@ -224,6 +224,12 @@ const it = {
|
||||
mountFailed: 'Montaggio immagine fallito',
|
||||
mountDesc:
|
||||
"In alcuni sistemi, è necessario espellere il disco virtuale sull'host remoto prima di montare l'immagine.",
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Smontaggio non riuscito',
|
||||
unmountDesc:
|
||||
"Su alcuni sistemi, è necessario espellere manualmente l'host remoto prima di smontare l'immagine.",
|
||||
@@ -292,6 +298,8 @@ const it = {
|
||||
input: "Inserisci un'immagine remota URL",
|
||||
ok: 'Ok',
|
||||
disabled: "La partizione /data è RO, quindi non possiamo scaricare l'immagine",
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Rilascia il file qui o fai clic per selezionarlo',
|
||||
inputfile: 'Inserisci il file immagine',
|
||||
NoISO: 'Nessuna ISO',
|
||||
@@ -452,6 +460,10 @@ const it = {
|
||||
'Smette di emulare i dispositivi virtuali, mantenendo solo il controllo di base HID',
|
||||
disk: 'Disco virtuale',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Rete virtuale',
|
||||
networkDesc: 'Monta la scheda di rete virtuale sull’host remoto',
|
||||
reboot: 'Riavvia',
|
||||
|
||||
@@ -222,6 +222,12 @@ const ja = {
|
||||
mountFailed: 'マウントに失敗しました',
|
||||
mountDesc:
|
||||
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
|
||||
diskOnWarn:
|
||||
'イメージをマウントすると仮想ディスクがオフになり、コンピュータから /data にアクセスできなくなります。続行しますか?',
|
||||
diskOffWarn:
|
||||
'仮想ディスクがオフになっているため、イメージのマウントには有効化が必要です。コンピュータの USB デバイスが再接続されます。続行しますか?',
|
||||
diskShared:
|
||||
'仮想ディスクが有効なため、このデバイスの /data は読み取り専用です。イメージを削除またはアップロードするには仮想ディスクを無効にしてください。',
|
||||
unmountFailed: 'アンマウントに失敗しました',
|
||||
unmountDesc:
|
||||
'一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
|
||||
@@ -291,6 +297,8 @@ const ja = {
|
||||
ok: 'OK',
|
||||
disabled:
|
||||
'/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません',
|
||||
diskShared: '先に仮想ディスクをオフにしてから、イメージのダウンロードを使用してください。',
|
||||
diskOff: '仮想ディスクをオフにする',
|
||||
uploadbox: 'ここにファイルをドロップするか、クリックして選択してください',
|
||||
inputfile: '画像ファイルを入力してください',
|
||||
NoISO: 'ISO なし',
|
||||
@@ -450,6 +458,11 @@ const ja = {
|
||||
'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
|
||||
disk: '仮想ディスク',
|
||||
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
|
||||
diskLocked: 'イメージのマウント中はオフです(オンにするとマウントが解除されます)',
|
||||
diskCancelMount:
|
||||
'仮想ディスクを有効にすると、マウント中のイメージは解除され、進行中のイメージのダウンロード/アップロードも中断されます。続行しますか?',
|
||||
diskBreakDownload:
|
||||
'仮想ディスクを有効にすると、イメージのダウンロードが中断されます。続行しますか?',
|
||||
network: '仮想ネットワークカード',
|
||||
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
|
||||
reboot: '再起動',
|
||||
|
||||
@@ -217,6 +217,12 @@ const ko = {
|
||||
mountFailed: '마운트 실패',
|
||||
mountDesc:
|
||||
'일부 시스템에서는 이미지를 마운트하기 전에 원격 호스트에서 가상 디스크를 제거해야 합니다.',
|
||||
diskOnWarn:
|
||||
'이미지를 마운트하면 가상 디스크가 꺼지고 컴퓨터에서 /data에 접근할 수 없게 됩니다. 계속하시겠습니까?',
|
||||
diskOffWarn:
|
||||
'가상 디스크가 꺼져 있어 이미지를 마운트하려면 먼저 켜야 합니다. 컴퓨터의 USB 장치가 다시 연결됩니다. 계속하시겠습니까?',
|
||||
diskShared:
|
||||
'가상 디스크가 켜져 있어 이 기기에서 /data는 읽기 전용입니다. 이미지를 삭제하거나 업로드하려면 가상 디스크를 끄세요.',
|
||||
unmountFailed: '언마운트 실패',
|
||||
unmountDesc:
|
||||
'일부 시스템에서는 이미지를 언마운트하기 전에 원격 호스트에서 수동으로 제거하여야 합니다.',
|
||||
@@ -285,6 +291,8 @@ const ko = {
|
||||
input: '원격 이미지 URL을 입력하세요.',
|
||||
ok: '확인',
|
||||
disabled: '/data 파티션이 읽기 전용(RO) 상태이므로 이미지를 다운로드할 수 없습니다.',
|
||||
diskShared: '먼저 가상 디스크를 끈 다음 이미지 다운로드를 사용하세요.',
|
||||
diskOff: '가상 디스크 끄기',
|
||||
uploadbox: '여기에 파일을 놓거나 클릭하여 선택하세요.',
|
||||
inputfile: '이미지 파일을 입력해주세요',
|
||||
NoISO: 'ISO 없음',
|
||||
@@ -442,6 +450,10 @@ const ko = {
|
||||
hidOnlyDesc: '가상 장치 에뮬레이션을 중지하고 기본 HID 제어만 유지합니다.',
|
||||
disk: '가상 디스크',
|
||||
diskDesc: '원격 호스트에서 가상 USB를 마운트합니다.',
|
||||
diskLocked: '이미지가 마운트된 동안 꺼짐 (켜면 마운트가 해제됩니다)',
|
||||
diskCancelMount:
|
||||
'가상 디스크를 켜면 마운트된 이미지가 해제되고 진행 중인 이미지 다운로드/업로드도 중단됩니다. 계속하시겠습니까?',
|
||||
diskBreakDownload: '가상 디스크를 켜면 이미지 다운로드가 중단됩니다. 계속하시겠습니까?',
|
||||
network: '가상 네트워크',
|
||||
networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.',
|
||||
reboot: '재부팅',
|
||||
|
||||
@@ -222,6 +222,12 @@ const nb = {
|
||||
mountFailed: 'Montering feilet',
|
||||
mountDesc:
|
||||
'På noen systemer er det nødvendig å koble fra den virtuelle disken på den eksterne verten før man kan montere arkivfilen.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Avmontering mislyktes',
|
||||
unmountDesc:
|
||||
'På noen systemer må du manuelt løse ut fra den eksterne verten før du demonterer bildet.',
|
||||
@@ -290,6 +296,8 @@ const nb = {
|
||||
input: 'Vennligst skriv inn et eksternt bilde URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partisjonen er RO, så vi kan ikke laste ned bildet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Slipp filen her eller klikk for å velge',
|
||||
inputfile: 'Vennligst skriv inn bildefilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
@@ -446,6 +454,10 @@ const nb = {
|
||||
hidOnlyDesc: 'Slutt å emulere virtuelle enheter, behold bare grunnleggende HID-kontroll',
|
||||
disk: 'Virtuell disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuelt nettverk',
|
||||
networkDesc: 'Monter virtuelt nettverkskort på den eksterne verten',
|
||||
reboot: 'Start på nytt',
|
||||
|
||||
@@ -225,6 +225,12 @@ const nl = {
|
||||
mountFailed: 'Koppelen mislukt',
|
||||
mountDesc:
|
||||
'In sommige systemen is het noodzakelijk om de virtuele schijf op de externe host uit te werpen voordat het image wordt gekoppeld.',
|
||||
diskOnWarn:
|
||||
'Het koppelen van de image schakelt de virtuele schijf uit, zodat de computer geen toegang meer heeft tot /data. Doorgaan?',
|
||||
diskOffWarn:
|
||||
'De virtuele schijf staat uit en moet aan staan om de image te koppelen. De USB-apparaten op de computer worden opnieuw herkend. Doorgaan?',
|
||||
diskShared:
|
||||
'De virtuele schijf staat aan, dus /data is hier alleen-lezen. Schakel deze uit om images te verwijderen of te uploaden.',
|
||||
unmountFailed: 'Ontkoppelen mislukt',
|
||||
unmountDesc:
|
||||
'Op sommige systemen moet u de image handmatig uitwerpen van de externe host voordat u de image ontkoppelt.',
|
||||
@@ -293,6 +299,8 @@ const nl = {
|
||||
input: 'Voer een externe afbeelding in URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partitie is RO, dus we kunnen de afbeelding niet downloaden',
|
||||
diskShared: 'Schakel eerst de virtuele schijf uit en gebruik daarna de image-download.',
|
||||
diskOff: 'Virtuele schijf uitschakelen',
|
||||
uploadbox: 'Zet het bestand hier neer of klik om te selecteren',
|
||||
inputfile: 'Voer het afbeeldingsbestand in',
|
||||
NoISO: 'Geen ISO',
|
||||
@@ -453,6 +461,11 @@ const nl = {
|
||||
'Stop met het emuleren van virtuele apparaten en behoud alleen de basisbesturing van HID',
|
||||
disk: 'Virtuele schijf',
|
||||
diskDesc: 'Koppel virtuele U-schijf aan de externe host',
|
||||
diskLocked: 'Uit zolang een image is gekoppeld (aanzetten breekt het koppelen af)',
|
||||
diskCancelMount:
|
||||
'Het inschakelen van de virtuele schijf breekt de gekoppelde image af en onderbreekt een lopende image-download of -upload. Doorgaan?',
|
||||
diskBreakDownload:
|
||||
'Het inschakelen van de virtuele schijf onderbreekt de image-download. Doorgaan?',
|
||||
network: 'Virtueel Netwerk',
|
||||
networkDesc: 'Koppel virtueel netwerk kaart aan de externe host',
|
||||
reboot: 'Opnieuw opstarten',
|
||||
|
||||
@@ -223,6 +223,12 @@ const pl = {
|
||||
mountFailed: 'Nie udało się zamontować obrazu',
|
||||
mountDesc:
|
||||
'W niektórych systemach wymagane jest wyjęcie dysku wirtualnego na zdalnym hoście przed zamontowaniem obrazu.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Odmontowanie nie powiodło się',
|
||||
unmountDesc:
|
||||
'W niektórych systemach należy ręcznie wysunąć obraz ze zdalnego hosta przed odmontowaniem obrazu.',
|
||||
@@ -291,6 +297,8 @@ const pl = {
|
||||
input: 'Proszę wprowadzić zdalny obraz URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partycja to RO, więc nie możemy pobrać obrazu',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Upuść plik tutaj lub kliknij, aby wybrać',
|
||||
inputfile: 'Proszę wprowadzić plik obrazu',
|
||||
NoISO: 'Brak ISO',
|
||||
@@ -450,6 +458,10 @@ const pl = {
|
||||
'Przestań emulować urządzenia wirtualne, zachowując jedynie podstawową kontrolę HID',
|
||||
disk: 'Dysk wirtualny',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Sieć wirtualna',
|
||||
networkDesc: 'Zamontuj wirtualną kartę sieciową na zdalnym hoście',
|
||||
reboot: 'Uruchom ponownie',
|
||||
|
||||
@@ -222,6 +222,12 @@ const pt_br = {
|
||||
mountFailed: 'Falha na Montagem',
|
||||
mountDesc:
|
||||
'Em alguns sistemas, é necessário ejetar o disco virtual no host remoto antes de montar a imagem.',
|
||||
diskOnWarn:
|
||||
'Montar a imagem desliga o disco virtual, então o computador não pode mais acessar /data. Continuar?',
|
||||
diskOffWarn:
|
||||
'O disco virtual está desligado e precisa ser ativado para montar a imagem. Os dispositivos USB do computador são reconectados. Continuar?',
|
||||
diskShared:
|
||||
'O disco virtual está ativado, então /data é somente leitura aqui. Desative-o para excluir ou enviar imagens.',
|
||||
unmountFailed: 'Falha na desmontagem',
|
||||
unmountDesc:
|
||||
'Em alguns sistemas, é necessário ejetar manualmente do host remoto antes de desmontar a imagem.',
|
||||
@@ -290,6 +296,8 @@ const pt_br = {
|
||||
input: 'Por favor, digite uma URL de imagem remota',
|
||||
ok: 'Ok',
|
||||
disabled: 'A partição /data é RO, então não podemos baixar a imagem',
|
||||
diskShared: 'Desligue primeiro o disco virtual e depois use o download de imagens.',
|
||||
diskOff: 'Desligar o disco virtual',
|
||||
uploadbox: 'Solte o arquivo aqui ou clique para selecionar',
|
||||
inputfile: 'Por favor insira o arquivo de imagem',
|
||||
NoISO: 'Sem ISO',
|
||||
@@ -448,6 +456,10 @@ const pt_br = {
|
||||
hidOnlyDesc: 'Pare de emular dispositivos virtuais, mantendo apenas o controle básico HID',
|
||||
disk: 'Disco Virtual',
|
||||
diskDesc: 'Montar U-disk virtual no host remoto',
|
||||
diskLocked: 'Desligado enquanto uma imagem estiver montada (ligá-lo cancela a montagem)',
|
||||
diskCancelMount:
|
||||
'Ativar o disco virtual cancela a imagem montada e interrompe um download ou upload de imagem em andamento. Continuar?',
|
||||
diskBreakDownload: 'Ativar o disco virtual interrompe o download de imagens. Continuar?',
|
||||
network: 'Rede Virtual',
|
||||
networkDesc: 'Montar placa de rede virtual no host remoto',
|
||||
reboot: 'Reiniciar',
|
||||
|
||||
@@ -222,6 +222,12 @@ const ru = {
|
||||
mountFailed: 'Монтирование образа не удалось',
|
||||
mountDesc:
|
||||
'В некоторых системах необходимо отсоединить виртуальный диск на удаленном хосте перед монтированием образа.',
|
||||
diskOnWarn:
|
||||
'Монтирование образа отключает виртуальный диск, и компьютер теряет доступ к /data. Продолжить?',
|
||||
diskOffWarn:
|
||||
'Виртуальный диск отключён, его нужно включить для монтирования образа. USB-устройства компьютера будут переподключены. Продолжить?',
|
||||
diskShared:
|
||||
'Виртуальный диск включён, поэтому /data здесь только для чтения. Отключите его, чтобы удалять или загружать образы.',
|
||||
unmountFailed: 'Не удалось размонтировать',
|
||||
unmountDesc:
|
||||
'В некоторых системах перед размонтированием образа необходимо вручную извлечь образ с удаленного хоста.',
|
||||
@@ -290,6 +296,8 @@ const ru = {
|
||||
input: 'Введите URL удаленного образа',
|
||||
ok: 'OK',
|
||||
disabled: 'Невозможно скачать образ, раздел /data находится в режиме только для чтения',
|
||||
diskShared: 'Сначала отключите виртуальный диск, затем используйте загрузку образов.',
|
||||
diskOff: 'Отключить виртуальный диск',
|
||||
uploadbox: 'Перетащите сюда файл или нажмите, чтобы выбрать',
|
||||
inputfile: 'Пожалуйста, введите файл изображения',
|
||||
NoISO: 'Нет ISO',
|
||||
@@ -450,6 +458,10 @@ const ru = {
|
||||
'Прекратить эмуляцию виртуальных устройств, сохранив только базовый контроль HID.',
|
||||
disk: 'Виртуальный диск',
|
||||
diskDesc: 'Смонтировать виртуальный USB диск на удаленном хосте',
|
||||
diskLocked: 'Отключён, пока смонтирован образ (включение отменит монтирование)',
|
||||
diskCancelMount:
|
||||
'Включение виртуального диска отменит смонтированный образ и прервёт текущую загрузку или выгрузку образа. Продолжить?',
|
||||
diskBreakDownload: 'Включение виртуального диска прервёт загрузку образов. Продолжить?',
|
||||
network: 'Виртуальная сеть',
|
||||
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте',
|
||||
reboot: 'Перезагрузка',
|
||||
|
||||
@@ -218,6 +218,12 @@ const se = {
|
||||
mountFailed: 'Montering misslyckades',
|
||||
mountDesc:
|
||||
'I vissa system måste den virtuella disken avmonteras på fjärrvärden innan avbildningen monteras.',
|
||||
diskOnWarn:
|
||||
'Montering av avbildningen stänger av den virtuella disken, så datorn kommer inte åt /data. Fortsätta?',
|
||||
diskOffWarn:
|
||||
'Den virtuella disken är avstängd och måste aktiveras för att montera avbildningen. Datorns USB-enheter ansluts på nytt. Fortsätta?',
|
||||
diskShared:
|
||||
'Den virtuella disken är aktiverad, så /data är skrivskyddad här. Stäng av den för att ta bort eller ladda upp avbildningar.',
|
||||
unmountFailed: 'Avmontering misslyckades',
|
||||
unmountDesc:
|
||||
'I vissa system måste du manuellt mata ut från fjärrvärden innan du avmonterar avbildningen.',
|
||||
@@ -286,6 +292,8 @@ const se = {
|
||||
input: 'Ange en fjärravbildnings-URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partitionen är skrivskyddad, kan inte hämta avbildning',
|
||||
diskShared: 'Stäng av den virtuella disken först och använd sedan nedladdningen.',
|
||||
diskOff: 'Stäng av den virtuella disken',
|
||||
uploadbox: 'Släpp filen här eller klicka för att välja',
|
||||
inputfile: 'Vänligen ange bildfilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
@@ -444,6 +452,11 @@ const se = {
|
||||
hidOnlyDesc: 'Sluta emulera virtuella enheter, behåll bara grundläggande HID kontroll',
|
||||
disk: 'Virtuell disk',
|
||||
diskDesc: 'Montera virtuell U-disk på fjärrvärden',
|
||||
diskLocked: 'Avstängd medan en avbildning är monterad (aktivering avbryter monteringen)',
|
||||
diskCancelMount:
|
||||
'Att aktivera den virtuella disken avbryter den monterade avbildningen och avbryter en pågående nedladdning eller uppladdning. Fortsätta?',
|
||||
diskBreakDownload:
|
||||
'Att aktivera den virtuella disken avbryter nedladdningen av avbildningar. Fortsätta?',
|
||||
network: 'Virtuellt nätverk',
|
||||
networkDesc: 'Montera virtuell nätverkskort på fjärrvärden',
|
||||
reboot: 'Starta om',
|
||||
|
||||
@@ -215,6 +215,12 @@ const th = {
|
||||
mountMode: 'โหมดเมาท์',
|
||||
mountFailed: 'การติดตั้งล้มเหลว',
|
||||
mountDesc: 'ในบางระบบ, จะต้อง eject ดิสก์จำลองก่อนที่จะใส่ดิสก์ใหม่.',
|
||||
diskOnWarn:
|
||||
'การเมานต์อิมเมจจะปิดดิสก์เสมือน ทำให้คอมพิวเตอร์เข้าถึง /data ไม่ได้อีกต่อไป ดำเนินการต่อหรือไม่?',
|
||||
diskOffWarn:
|
||||
'ดิสก์เสมือนปิดอยู่ ต้องเปิดก่อนจึงจะเมานต์อิมเมจได้ อุปกรณ์ USB ของคอมพิวเตอร์จะเชื่อมต่อใหม่ ดำเนินการต่อหรือไม่?',
|
||||
diskShared:
|
||||
'ดิสก์เสมือนเปิดอยู่ ดังนั้น /data จึงเป็นแบบอ่านอย่างเดียว ปิดดิสก์เสมือนเพื่อลบหรืออัปโหลดอิมเมจ',
|
||||
unmountFailed: 'การถอนติดตั้งล้มเหลว',
|
||||
unmountDesc:
|
||||
'ในบางระบบ คุณต้องดีดออกจากโฮสต์ระยะไกลด้วยตนเองก่อนที่จะยกเลิกการต่อเชื่อมอิมเมจ',
|
||||
@@ -283,6 +289,8 @@ const th = {
|
||||
input: 'กรุณาใส่ URL ของอิมเมจระยะไกล',
|
||||
ok: 'ยืนยัน',
|
||||
disabled: '/data มีการตั้งค่าเป็นอ่านอย่างเดียว ดังนั้นเราไม่สามารถดาวโหลด Disk Image ได้',
|
||||
diskShared: 'ปิดดิสก์เสมือนก่อน แล้วจึงใช้การดาวน์โหลดอิมเมจ',
|
||||
diskOff: 'ปิดดิสก์เสมือน',
|
||||
uploadbox: 'วางไฟล์ที่นี่หรือคลิกเพื่อเลือก',
|
||||
inputfile: 'กรุณากรอกไฟล์ภาพ',
|
||||
NoISO: 'ไม่มี ISO',
|
||||
@@ -439,6 +447,10 @@ const th = {
|
||||
hidOnlyDesc: 'หยุดการจำลองอุปกรณ์เสมือน โดยคงไว้เพียงการควบคุม HID พื้นฐานเท่านั้น',
|
||||
disk: 'ดิสก์จำลอง',
|
||||
diskDesc: 'เปิดใช้งาน U-disk จำลอง',
|
||||
diskLocked: 'ปิดอยู่ขณะเมานต์อิมเมจ (เปิดจะยกเลิกการเมานต์)',
|
||||
diskCancelMount:
|
||||
'การเปิดดิสก์เสมือนจะยกเลิกการเมานต์อิมเมจ และขัดขวางการดาวน์โหลดหรืออัปโหลดอิมเมจที่กำลังดำเนินอยู่ ดำเนินการต่อหรือไม่?',
|
||||
diskBreakDownload: 'การเปิดดิสก์เสมือนจะขัดขวางการดาวน์โหลดอิมเมจ ดำเนินการต่อหรือไม่?',
|
||||
network: 'เครือข่ายจำลอง',
|
||||
networkDesc: 'เปิดใช้งานอุปกรณ์เครือข่ายจำลอง',
|
||||
reboot: 'รีบูต',
|
||||
|
||||
@@ -221,6 +221,12 @@ const tr = {
|
||||
mountFailed: 'Bağlantı başarısız oldu',
|
||||
mountDesc:
|
||||
'Bazı sistemlerde, disk imajını bağlamadan önce uzak ana bilgisayardaki sanal diski çıkarmak gerekir.',
|
||||
diskOnWarn:
|
||||
'İmajı bağlamak sanal diski kapatır, böylece bilgisayar /data dizinine erişemez. Devam edilsin mi?',
|
||||
diskOffWarn:
|
||||
'Sanal disk kapalı ve imajı bağlamak için açılması gerekiyor. Bilgisayardaki USB aygıtları yeniden tanımlanır. Devam edilsin mi?',
|
||||
diskShared:
|
||||
'Sanal disk açık, bu nedenle /data burada salt okunur. İmaj silmek veya yüklemek için sanal diski kapatın.',
|
||||
unmountFailed: 'Bağlantıyı kesme işlemi başarısız oldu',
|
||||
unmountDesc:
|
||||
'Bazı sistemlerde, görüntünün bağlantısını kesmeden önce uzak ana bilgisayardan manuel olarak çıkarmanız gerekir.',
|
||||
@@ -289,6 +295,8 @@ const tr = {
|
||||
input: 'Uzak imaj URL’sini girin',
|
||||
ok: 'Tamam',
|
||||
disabled: '/data bölüntüsü salt okunur modda, disk imajı indirilemiyor.',
|
||||
diskShared: 'Önce sanal diski kapatın, sonra imaj indirmeyi kullanın.',
|
||||
diskOff: 'Sanal diski kapat',
|
||||
uploadbox: 'Dosyayı buraya bırakın veya seçmek için tıklayın',
|
||||
inputfile: 'Lütfen resim dosyasını giriniz',
|
||||
NoISO: 'ISO yok',
|
||||
@@ -449,6 +457,10 @@ const tr = {
|
||||
'Yalnızca temel HID kontrolünü koruyarak sanal aygıtları taklit etmeyi bırakın',
|
||||
disk: 'Sanal Disk',
|
||||
diskDesc: "Sanal U-disk'i uzak ana bilgisayara bağla",
|
||||
diskLocked: 'Bir imaj bağlıyken kapalı (açmak bağlamayı iptal eder)',
|
||||
diskCancelMount:
|
||||
'Sanal diski açmak, bağlı imajı iptal eder ve sürmekte olan imaj indirme veya yüklemeyi keser. Devam edilsin mi?',
|
||||
diskBreakDownload: 'Sanal diski açmak imaj indirmeyi keser. Devam edilsin mi?',
|
||||
network: 'Sanal Ağ',
|
||||
networkDesc: 'Sanal ağ kartını uzak ana bilgisayara bağla',
|
||||
reboot: 'Yeniden Başlat',
|
||||
|
||||
@@ -223,6 +223,12 @@ const uk = {
|
||||
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
|
||||
mountDesc:
|
||||
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
|
||||
diskOnWarn:
|
||||
"Монтування образу вимикає віртуальний диск, тому комп'ютер більше не має доступу до /data. Продовжити?",
|
||||
diskOffWarn:
|
||||
"Віртуальний диск вимкнено, його треба увімкнути для монтування образу. USB-пристрої комп'ютера буде перепідключено. Продовжити?",
|
||||
diskShared:
|
||||
'Віртуальний диск увімкнено, тому /data тут лише для читання. Вимкніть його, щоб видаляти або завантажувати образи.',
|
||||
unmountFailed: 'Не вдалося розмонтувати',
|
||||
unmountDesc:
|
||||
'У деяких системах вам потрібно вручну вийти з віддаленого хоста перед демонтуванням образу.',
|
||||
@@ -291,6 +297,9 @@ const uk = {
|
||||
input: 'Введіть URL-адресу віддаленого образу',
|
||||
ok: 'Так',
|
||||
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ',
|
||||
diskShared:
|
||||
'Спочатку вимкніть віртуальний диск, а потім скористайтеся завантаженням образів.',
|
||||
diskOff: 'Вимкнути віртуальний диск',
|
||||
uploadbox: 'Перетягніть файл сюди або натисніть, щоб вибрати',
|
||||
inputfile: 'Будь ласка, введіть файл зображення',
|
||||
NoISO: 'Немає ISO',
|
||||
@@ -447,6 +456,11 @@ const uk = {
|
||||
hidOnlyDesc: 'Зупиніть емуляцію віртуальних пристроїв, зберігши лише базовий контроль HID',
|
||||
disk: 'Віртуальний диск',
|
||||
diskDesc: 'Монтувати віртуальний U-диск на віддаленому хості',
|
||||
diskLocked: 'Вимкнено, поки змонтовано образ (увімкнення скасує монтування)',
|
||||
diskCancelMount:
|
||||
'Увімкнення віртуального диска скасує змонтований образ і перерве поточне завантаження чи вивантаження образу. Продовжити?',
|
||||
diskBreakDownload:
|
||||
'Увімкнення віртуального диска перерве завантаження образів. Продовжити?',
|
||||
network: 'Віртуальна мережа',
|
||||
networkDesc: 'Встановити віртуальну мережеву карту на віддаленому хості',
|
||||
reboot: 'Перезавантажити',
|
||||
|
||||
@@ -220,6 +220,12 @@ const vi = {
|
||||
mountMode: 'Chế độ gắn kết',
|
||||
mountFailed: 'Mount thất bại',
|
||||
mountDesc: 'Trong một số hệ thống, cần phải eject đĩa ảo trên máy remote trước khi mount.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Tháo lắp không thành công',
|
||||
unmountDesc:
|
||||
'Trên một số hệ thống, bạn cần đẩy hình ảnh ra khỏi máy chủ từ xa theo cách thủ công trước khi ngắt kết nối hình ảnh.',
|
||||
@@ -288,6 +294,8 @@ const vi = {
|
||||
input: 'Vui lòng nhập hình ảnh từ xa URL',
|
||||
ok: 'OK',
|
||||
disabled: '/data phân vùng là RO nên không tải được image',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Thả file vào đây hoặc bấm vào để chọn',
|
||||
inputfile: 'Vui lòng nhập File hình ảnh',
|
||||
NoISO: 'Không có ISO',
|
||||
@@ -445,6 +453,10 @@ const vi = {
|
||||
hidOnlyDesc: 'Dừng mô phỏng các thiết bị ảo, chỉ giữ lại điều khiển HID cơ bản',
|
||||
disk: 'Đĩa ảo',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Mạng ảo',
|
||||
networkDesc: 'Gắn card mạng ảo trên máy chủ từ xa',
|
||||
reboot: 'Khởi động lại',
|
||||
|
||||
@@ -215,6 +215,10 @@ const zh = {
|
||||
mountMode: '挂载模式',
|
||||
mountFailed: '挂载失败',
|
||||
mountDesc: '在某些系统中,需要在远程主机中弹出虚拟硬盘后再挂载镜像。',
|
||||
diskOnWarn: '挂载镜像会关闭虚拟U盘,电脑将无法再访问 /data 目录。是否继续?',
|
||||
diskOffWarn:
|
||||
'虚拟U盘当前已关闭,需要先开启才能挂载镜像,电脑上的 USB 设备会短暂断开重连。是否继续?',
|
||||
diskShared: '虚拟U盘已开启,本机 /data 为只读;关闭虚拟U盘后才能删除或上传镜像。',
|
||||
unmountFailed: '卸载失败',
|
||||
unmountDesc: '在某些系统中,需要在远程主机中手动弹出后再卸载镜像。',
|
||||
refresh: '刷新镜像列表',
|
||||
@@ -282,6 +286,8 @@ const zh = {
|
||||
input: '请输入远程镜像 URL',
|
||||
ok: '确定',
|
||||
disabled: '/data 是只读分区,无法下载镜像',
|
||||
diskShared: '请先关闭虚拟U盘功能,再使用镜像下载功能',
|
||||
diskOff: '关闭虚拟U盘功能',
|
||||
uploadbox: '将文件拖放到此处或单击选择',
|
||||
inputfile: '请输入镜像文件',
|
||||
NoISO: '无 ISO',
|
||||
@@ -436,6 +442,10 @@ const zh = {
|
||||
hidOnlyDesc: '该模式下不再挂载虚拟设备,仅保留基础的 HID 控制功能。',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
diskLocked: '挂载镜像期间已关闭(开启会取消镜像挂载)',
|
||||
diskCancelMount:
|
||||
'开启虚拟U盘会取消当前挂载的镜像,并中断正在进行的镜像下载或上传,是否继续?',
|
||||
diskBreakDownload: '开启虚拟U盘会中断镜像下载功能,是否继续?',
|
||||
network: '虚拟网卡',
|
||||
networkDesc: '在远程主机中挂载虚拟网卡',
|
||||
reboot: '重新启动',
|
||||
|
||||
@@ -212,6 +212,10 @@ const zh_tw = {
|
||||
mountMode: '掛載模式',
|
||||
mountFailed: '掛載失敗',
|
||||
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
|
||||
diskOnWarn: '掛載映像檔會關閉虛擬隨身碟,電腦將無法再存取 /data 目錄。是否繼續?',
|
||||
diskOffWarn:
|
||||
'虛擬隨身碟目前已關閉,需要先開啟才能掛載映像檔,電腦上的 USB 裝置會短暫斷開重連。是否繼續?',
|
||||
diskShared: '虛擬隨身碟已開啟,本機 /data 為唯讀;關閉虛擬隨身碟後才能刪除或上傳映像檔。',
|
||||
unmountFailed: '解除安裝失敗',
|
||||
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
|
||||
refresh: '重新整理映像檔列表',
|
||||
@@ -279,6 +283,8 @@ const zh_tw = {
|
||||
input: '請輸入映像檔的下載 URL',
|
||||
ok: '確定',
|
||||
disabled: '/data 為唯讀目錄,無法下載映像檔',
|
||||
diskShared: '請先關閉虛擬隨身碟功能,再使用映像檔下載功能',
|
||||
diskOff: '關閉虛擬隨身碟功能',
|
||||
uploadbox: '將檔案拖曳到此處或按一下選擇',
|
||||
inputfile: '請輸入映像檔案',
|
||||
NoISO: '無 ISO',
|
||||
@@ -433,6 +439,10 @@ const zh_tw = {
|
||||
hidOnlyDesc: '停止模擬虛擬設備,僅保留基礎 HID 控制',
|
||||
disk: '虛擬隨身碟',
|
||||
diskDesc: '在遠端主機上連接虛擬隨身碟',
|
||||
diskLocked: '掛載映像檔期間已關閉(開啟會取消映像檔掛載)',
|
||||
diskCancelMount:
|
||||
'開啟虛擬隨身碟會取消目前掛載的映像檔,並中斷正在進行的映像檔下載或上傳,是否繼續?',
|
||||
diskBreakDownload: '開啟虛擬隨身碟會中斷映像檔下載功能,是否繼續?',
|
||||
network: '虛擬網卡',
|
||||
networkDesc: '在遠端主機上新增虛擬網卡',
|
||||
reboot: '重新啟動',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DownloadIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cancelDownloadImage, downloadImage, imageEnabled, statusImage } from '@/api/download.ts';
|
||||
import { getVirtualDevice, updateVirtualDevice } from '@/api/virtual-device.ts';
|
||||
import { keyboardLockAtom } from '@/jotai/keyboard.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
@@ -23,6 +24,8 @@ export const DownloadImage = () => {
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isRemoteDownloading, setIsRemoteDownloading] = useState(false);
|
||||
const [diskEnabled, setDiskEnabled] = useState(false);
|
||||
const [isDiskShared, setIsDiskShared] = useState(false);
|
||||
const [isDisablingDisk, setIsDisablingDisk] = useState(false);
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
@@ -47,6 +50,34 @@ export const DownloadImage = () => {
|
||||
.catch(() => {
|
||||
setDiskEnabled(false);
|
||||
});
|
||||
|
||||
// /data is read-only while the virtual disk serves it to the host
|
||||
getVirtualDevice()
|
||||
.then((res) => {
|
||||
setIsDiskShared(!!res.data?.disk);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDiskShared(false);
|
||||
});
|
||||
}
|
||||
|
||||
// hand /data back to the KVM, which makes the download possible again
|
||||
function disableDisk() {
|
||||
if (isDisablingDisk) return;
|
||||
setIsDisablingDisk(true);
|
||||
|
||||
updateVirtualDevice('disk')
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
checkDiskEnabled();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDisablingDisk(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
@@ -309,7 +340,16 @@ export const DownloadImage = () => {
|
||||
<Divider style={{ margin: '10px 0 10px 0' }} />
|
||||
|
||||
{!diskEnabled ? (
|
||||
<div className="text-red-500">{t('download.disabled')}</div>
|
||||
isDiskShared ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-neutral-300">{t('download.diskShared')}</div>
|
||||
<Button type="primary" block loading={isDisablingDisk} onClick={disableDisk}>
|
||||
{t('download.diskOff')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-500">{t('download.disabled')}</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx';
|
||||
import {
|
||||
ArrowBigDownDashIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
CircleAlertIcon,
|
||||
LoaderCircleIcon,
|
||||
PackageIcon,
|
||||
PackageSearchIcon,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/storage.ts';
|
||||
import { getVirtualDevice, updateVirtualDevice } from '@/api/virtual-device.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
const imageUpdatedEvent = 'nanokvm:image-updated';
|
||||
@@ -33,11 +35,15 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState('');
|
||||
const [deletingImage, setDeletingImage] = useState('');
|
||||
const [diskPrompt, setDiskPrompt] = useState('');
|
||||
const [pendingImage, setPendingImage] = useState('');
|
||||
const [isDiskShared, setIsDiskShared] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
getImages();
|
||||
getDiskState();
|
||||
|
||||
const handleImageUpdated = () => {
|
||||
getImages();
|
||||
@@ -86,9 +92,53 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
// mount/unmount image
|
||||
// mounting an image takes the mass storage device away from the virtual disk,
|
||||
// so ask the user before the virtual disk changes on the computer
|
||||
function mountImage(image: string) {
|
||||
if (mountingImage) return;
|
||||
|
||||
// unmounting leaves the virtual disk alone
|
||||
if (mountedImage === image) {
|
||||
doMountImage(image);
|
||||
return;
|
||||
}
|
||||
|
||||
getVirtualDevice().then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setDiskPrompt(rsp.data?.disk ? 'on' : 'off');
|
||||
setPendingImage(image);
|
||||
});
|
||||
}
|
||||
|
||||
// turn the virtual disk on, then mount
|
||||
async function confirmMountImage() {
|
||||
const image = pendingImage;
|
||||
const prompt = diskPrompt;
|
||||
|
||||
setPendingImage('');
|
||||
setDiskPrompt('');
|
||||
|
||||
if (!image) return;
|
||||
|
||||
// the media device only exists while the virtual disk is enabled
|
||||
if (prompt === 'off') {
|
||||
const rsp = await updateVirtualDevice('disk');
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
doMountImage(image);
|
||||
}
|
||||
|
||||
// mount/unmount image
|
||||
function doMountImage(image: string) {
|
||||
if (mountingImage) return;
|
||||
setMountingImage(image);
|
||||
|
||||
client.close();
|
||||
@@ -111,9 +161,23 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
.finally(() => {
|
||||
setMountingImage('');
|
||||
client.connect();
|
||||
getDiskState();
|
||||
});
|
||||
}
|
||||
|
||||
// the host writes /data while the virtual disk serves it, so images are
|
||||
// read-only here and can only be mounted, not deleted or uploaded
|
||||
function getDiskState() {
|
||||
getVirtualDevice().then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDiskShared(!!rsp.data?.disk);
|
||||
});
|
||||
}
|
||||
|
||||
// show delete image modal
|
||||
function showDeleteModal(e: any, image: string) {
|
||||
e.stopPropagation();
|
||||
@@ -121,7 +185,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
const isMounted = mountedImage === image;
|
||||
const isDeleting = deletingImage !== '';
|
||||
|
||||
if (isMounted || isDeleting) {
|
||||
if (isMounted || isDeleting || isDiskShared) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,6 +251,13 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDiskShared && (
|
||||
<div className="flex items-start space-x-1 pb-2 text-xs text-neutral-400">
|
||||
<CircleAlertIcon size={14} className="mt-[2px] shrink-0" />
|
||||
<span>{t('image.diskShared')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex max-h-[400px] flex-col overflow-y-auto pb-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
@@ -215,21 +286,16 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[24px] w-[24px] items-center justify-center rounded hover:bg-neutral-500/50',
|
||||
mountedImage === image
|
||||
? 'cursor-not-allowed text-neutral-500'
|
||||
: 'text-neutral-300 hover:text-red-500'
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
className="h-[24px] w-[24px] p-0"
|
||||
icon={<Trash2Icon size={16} />}
|
||||
disabled={mountedImage === image || isDiskShared}
|
||||
loading={deletingImage === image}
|
||||
onClick={(e) => showDeleteModal(e, image)}
|
||||
>
|
||||
{deletingImage === image ? (
|
||||
<LoaderCircleIcon className="animate-spin text-red-500" size={16} />
|
||||
) : (
|
||||
<Trash2Icon size={16} />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,6 +320,26 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('image.attention')}
|
||||
open={diskPrompt !== ''}
|
||||
width={520}
|
||||
footer={null}
|
||||
onCancel={() => setDiskPrompt('')}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-1 pb-10">
|
||||
<p>{t(diskPrompt === 'on' ? 'image.diskOnWarn' : 'image.diskOffWarn')}</p>
|
||||
<Typography.Text code>{pendingImage.replace(/^.*[\\/]/, '')}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-3 pb-3">
|
||||
<Button type="primary" onClick={confirmMountImage}>
|
||||
{t('image.okBtn')}
|
||||
</Button>
|
||||
<Button onClick={() => setDiskPrompt('')}>{t('image.cancelBtn')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ export const Image = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [mode, setMode] = useState('mass-storage');
|
||||
|
||||
const imageUpdatedEvent = 'nanokvm:image-updated';
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'mass-storage',
|
||||
@@ -41,6 +43,21 @@ export const Image = () => {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
getImageState();
|
||||
|
||||
const handleImageUpdated = () => {
|
||||
getImageState();
|
||||
};
|
||||
window.addEventListener(imageUpdatedEvent, handleImageUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(imageUpdatedEvent, handleImageUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// readers of the mounted image and of the CD-ROM flag: the icon state and the
|
||||
// mode selector here, and the same state in the mount panel
|
||||
function getImageState() {
|
||||
api.getMountedImage().then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setIsMounted(!!rsp.data?.file);
|
||||
@@ -52,7 +69,7 @@ export const Image = () => {
|
||||
setMode(rsp.data?.cdrom === 1 ? 'cd-rom' : 'mass-storage');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function toggleModal(open: boolean) {
|
||||
setIsModalOpen(open);
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { Button, Modal, Switch } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cancelDownloadImage, statusImage } from '@/api/download.ts';
|
||||
import { getHidMode } from '@/api/hid.ts';
|
||||
import { getMountedImage } from '@/api/storage.ts';
|
||||
import * as api from '@/api/virtual-device.ts';
|
||||
|
||||
const imageUpdatedEvent = 'nanokvm:image-updated';
|
||||
|
||||
export const VirtualDevices = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isHidOnlyMode, setIsHidOnlyMode] = useState(false);
|
||||
const [isDiskEnabled, setIsDiskEnabled] = useState(false);
|
||||
const [isNetworkEnabled, setIsNetworkEnabled] = useState(false);
|
||||
const [isImageMounted, setIsImageMounted] = useState(false);
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState<'' | 'disk' | 'network'>('');
|
||||
|
||||
useEffect(() => {
|
||||
getHidOnlyMode();
|
||||
getVirtualDevice();
|
||||
getImageState();
|
||||
}, []);
|
||||
|
||||
async function getHidOnlyMode() {
|
||||
@@ -46,6 +53,20 @@ export const VirtualDevices = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageState() {
|
||||
try {
|
||||
const rsp = await getMountedImage();
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImageMounted(!!rsp.data?.file);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(device: 'disk' | 'network') {
|
||||
if (loading) return;
|
||||
setLoading(device);
|
||||
@@ -58,6 +79,10 @@ export const VirtualDevices = () => {
|
||||
}
|
||||
|
||||
await getVirtualDevice();
|
||||
await getImageState();
|
||||
|
||||
// the desktop shows the mounted image and the CD-ROM flag too
|
||||
window.dispatchEvent(new Event(imageUpdatedEvent));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
@@ -65,6 +90,28 @@ export const VirtualDevices = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// enabling the disk takes /data away from the mounted image and from the
|
||||
// image downloader, which writes there
|
||||
async function toggleDisk() {
|
||||
if (isDiskEnabled) {
|
||||
await update('disk');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmOpen(true);
|
||||
}
|
||||
|
||||
// a transfer writes to /data, which the host takes over
|
||||
async function stopTransfer() {
|
||||
const rsp = await statusImage();
|
||||
if (rsp.code !== 0 || rsp.data?.status !== 'in_progress') return;
|
||||
|
||||
const cancel = await cancelDownloadImage();
|
||||
if (cancel.code !== 0) {
|
||||
console.log(cancel.msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHidOnlyMode) {
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-10">
|
||||
@@ -84,16 +131,47 @@ export const VirtualDevices = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span>{t('settings.device.disk')}</span>
|
||||
<span className="text-xs text-neutral-500">{t('settings.device.diskDesc')}</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{isImageMounted ? t('settings.device.diskLocked') : t('settings.device.diskDesc')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isDiskEnabled}
|
||||
loading={loading === 'disk'}
|
||||
onChange={() => update('disk')}
|
||||
/>
|
||||
<Switch checked={isDiskEnabled} loading={loading === 'disk'} onChange={toggleDisk} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('image.attention')}
|
||||
open={isConfirmOpen}
|
||||
width={520}
|
||||
footer={null}
|
||||
onCancel={() => setIsConfirmOpen(false)}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-1 pb-10">
|
||||
<p>
|
||||
{t(
|
||||
isImageMounted
|
||||
? 'settings.device.diskCancelMount'
|
||||
: 'settings.device.diskBreakDownload'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-3 pb-3">
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading === 'disk'}
|
||||
onClick={async () => {
|
||||
setIsConfirmOpen(false);
|
||||
await stopTransfer();
|
||||
await update('disk');
|
||||
}}
|
||||
>
|
||||
{t('settings.device.okBtn')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsConfirmOpen(false)}>{t('settings.device.cancelBtn')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Virtual Network */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-1">
|
||||
|
||||
@@ -3,7 +3,8 @@ import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
|
||||
import { KeyboardReact as Keyboard } from 'react-simple-keyboard';
|
||||
import type { KeyboardButtonTheme } from 'react-simple-keyboard';
|
||||
import { Drawer } from 'vaul';
|
||||
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
|
||||
Reference in New Issue
Block a user