diff --git a/kvmapp/system/init.d/S01fs b/kvmapp/system/init.d/S01fs index 3391d664..84d8361e 100755 --- a/kvmapp/system/init.d/S01fs +++ b/kvmapp/system/init.d/S01fs @@ -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" diff --git a/kvmapp/system/init.d/S03usbdev b/kvmapp/system/init.d/S03usbdev index 3e4c3180..6ee9ce81 100755 --- a/kvmapp/system/init.d/S03usbdev +++ b/kvmapp/system/init.d/S03usbdev @@ -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}" ] diff --git a/server/service/download/service.go b/server/service/download/service.go index 473dd229..3c0e61dc 100644 --- a/server/service/download/service.go +++ b/server/service/download/service.go @@ -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) } diff --git a/server/service/download/service_test.go b/server/service/download/service_test.go new file mode 100644 index 00000000..a2580e4e --- /dev/null +++ b/server/service/download/service_test.go @@ -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") + } +} diff --git a/server/service/storage/image.go b/server/service/storage/image.go index 40ece5ae..666b65d1 100644 --- a/server/service/storage/image.go +++ b/server/service/storage/image.go @@ -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 diff --git a/server/service/storage/image_test.go b/server/service/storage/image_test.go new file mode 100644 index 00000000..7b854691 --- /dev/null +++ b/server/service/storage/image_test.go @@ -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) + } + } +} diff --git a/server/service/vm/virtual-device.go b/server/service/vm/virtual-device.go index 2eb40e98..6a297226 100644 --- a/server/service/vm/virtual-device.go +++ b/server/service/vm/virtual-device.go @@ -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) diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index b1cf41ca..9133d9c9 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -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', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index d571e790..84e1ef77 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -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', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 9eabeda3..72cf44fb 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -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', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 2df4ce4b..c319678a 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -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', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 1e2e676c..ee1b4915 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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', diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 76d25c11..ae6c1bb0 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -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', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 699985af..f8883ac9 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -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', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 904f29b7..81c319ac 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -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', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 9bbb89e2..1b027a2c 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -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', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 1dd9c6c7..8d6156c8 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -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', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 290a119a..e1008b1e 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -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: '再起動', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 5d241764..2fcc42c1 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -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: '재부팅', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 3fcf9b6a..2d86a490 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -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', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index 1e7c6d5b..928f030f 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -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', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 8613e410..7ec33660 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -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', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 8309dcab..6410bcf4 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -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', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index ab152418..a1133725 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -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: 'Перезагрузка', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 968dd456..e0bf4bd3 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -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', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index f4d2f8f9..2ce4d673 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -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: 'รีบูต', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 9e6361c7..963eb6cf 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -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', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index c5b76d5d..406cbee0 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -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: 'Перезавантажити', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 223554e2..2596f7e9 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -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', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 4feecf98..159c4fe0 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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: '重新启动', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index d120728b..40a13d60 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -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: '重新啟動', diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index f50ca28a..8ec043d0 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -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(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 = () => { {!diskEnabled ? ( -
{t('download.disabled')}
+ isDiskShared ? ( +
+
{t('download.diskShared')}
+ +
+ ) : ( +
{t('download.disabled')}
+ ) ) : (
diff --git a/web/src/pages/desktop/menu/image/images.tsx b/web/src/pages/desktop/menu/image/images.tsx index d72281dc..2b1709d1 100644 --- a/web/src/pages/desktop/menu/image/images.tsx +++ b/web/src/pages/desktop/menu/image/images.tsx @@ -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 && ( +
+ + {t('image.diskShared')} +
+ )} +
{images.map((image) => (
{ )}
-
} + disabled={mountedImage === image || isDiskShared} + loading={deletingImage === image} onClick={(e) => showDeleteModal(e, image)} - > - {deletingImage === image ? ( - - ) : ( - - )} -
+ />
))}
@@ -254,6 +320,26 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
+ setDiskPrompt('')} + > +
+

{t(diskPrompt === 'on' ? 'image.diskOnWarn' : 'image.diskOffWarn')}

+ {pendingImage.replace(/^.*[\\/]/, '')} +
+ +
+ + +
+
+ {contextHolder} ); diff --git a/web/src/pages/desktop/menu/image/index.tsx b/web/src/pages/desktop/menu/image/index.tsx index 21b453b8..7228ab8f 100644 --- a/web/src/pages/desktop/menu/image/index.tsx +++ b/web/src/pages/desktop/menu/image/index.tsx @@ -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); diff --git a/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx b/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx index c8b72763..94663580 100644 --- a/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx +++ b/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx @@ -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 (
@@ -84,16 +131,47 @@ export const VirtualDevices = () => {
{t('settings.device.disk')} - {t('settings.device.diskDesc')} + + {isImageMounted ? t('settings.device.diskLocked') : t('settings.device.diskDesc')} +
- update('disk')} - /> +
+ setIsConfirmOpen(false)} + > +
+

+ {t( + isImageMounted + ? 'settings.device.diskCancelMount' + : 'settings.device.diskBreakDownload' + )} +

+
+ +
+ + +
+
+ {/* Virtual Network */}