Files
NanoKVM-MIRROR/server/utils/progress.go
watermeko 5572e1de35 feat(storage): browse a medium and exchange files with the target machine (#957)
* feat(storage): browse a medium and exchange files with the target machine

The image menu and the download page are replaced by one storage page: it
lists the media the emulated USB disk can carry, mounts and ejects them, and
browses the files inside a medium over that same gadget, so the two machines
exchange files without going through the terminal.

Mediated media:

* Add the media API (list, state, mount, eject, delete, download, files) and a
  file browser on top of it, and keep the pre-2.6 image endpoints working as a
  compatibility layer over the same code.
* Add diskfs: an image is mounted through a loop device so the kernel's exFAT
  and FAT drivers read it, and the data partition is browsed in place, because
  the emulated disk already hands that very directory to the target machine.
* Keep the one-writer policy the read-only mounts introduced: an image is
  served read-only in every mode, and the whole data partition is remounted
  read-only here while the target machine is its writer, through the same
  SetDataWritable the virtual disk switch uses. The file manager refuses those
  writes itself as well, so a request does not depend on a mount state.
* Name the mounted medium and the USB device after the image file, cut to the
  SCSI inquiry product field and the USB product string, and restore the
  defaults when the medium is released.
* Close the HID devices around a gadget re-enumeration and reopen them after,
  since mounting and a forced eject drop them.
* Report transfers through one job slot with progress, queueing behind a
  running transfer, and a free space preflight before an upload starts. Image
  uploads keep the volume descriptor and partition table check, so an unusable
  image is rejected while it streams.

The shared folder view:

* Refresh it on a timer while the target machine owns the partition. The
  target's block level writes land in this device's page cache, which the
  mounted exFAT shares, so the file data here is already current - but the
  dentry and inode caches are not, and a file the target grew keeps the size
  it was first read with. Dropping those caches and pushing a storage event
  makes the web UI follow the target machine within a second of its own
  writeback. The partition's write counter cannot drive this: it only moves
  once this side writes its dirty pages back, which is tens of seconds later
  or never.
* Send the refresh notifications from their own goroutine, so a client that
  stopped reading cannot hold up the refresh loop.

The web UI:

* Add the storage page: the medium list with mount and eject, the file browser
  with search and sorting, uploads, downloads and deletes, and transfer
  progress.
* Gate writing on the target machine owning the disk - image uploads and file
  uploads alike - instead of letting the server answer with an error the page
  has to surface, and drop the medium rows' download and delete buttons there
  too.
* Give the shared folder, .iso and .img their own glyphs, and pick the file
  icon from the extension, so a listing can be read at a glance.
* Name the partition the way the storage page does in the desktop menu as
  well, instead of showing the API's untranslated string.
* Read the mounted medium in Settings - Device from the media state, now that
  the storage API it used is gone.
* Tell an ISO 9660 image apart from a filesystem that cannot be read at all:
  the kernel of this device carries no iso9660 support (CONFIG_ISO9660_FS), so
  the contents of an ISO cannot be listed while the image itself is served to
  the target machine like any other medium.

Localization:

* Translate the new strings for every locale, keep the Settings - Device
  strings the read-only mounts added, and drop the keys the retired image menu
  and download page left behind.

Chores:

* Remove the desktop image menu, the download page and the API they used.

* fix(storage): harden media file transfers

Serialize medium deletion with gadget attachment and restore /data write access after failed mount transitions.

Use available filesystem space for upload limits, refine virtual-device and shared-file handling, and update media path and installation test coverage.

Pin an image while it is streamed out, so a writable mount cannot start between the check and the last byte; sweep stale upload temporaries from the whole data partition at startup, which the file manager itself cannot delete; and preflight URL downloads against the response size, which only the upload path did.

Show dot files in the file browser again: only the two transfer temporary prefixes stay hidden, which is what the dot filter was for.

Delete folders recursively after the web UI confirms, refusing when an attached or mounted medium lives inside the directory; accept every .iso and .img upload by name instead of sniffing volume descriptors; and show image and file transfers in one progress display at the top of the storage page.

* fix(storage): make cache drops work and close delete and gadget races

- drop_caches only accepts 1..4 on 5.10: write 4 once to silence the log,
  then 2/3. Writing 6/7 failed with EINVAL, so the shared folder never
  saw what the target machine wrote.
- Resolve a delete path to its on-disk spelling (exFAT is case-insensitive)
  before checking it against the attached and loop-mounted media, refuse
  deleting a loop-mounted image or a folder holding an upload target, and
  fail closed when the disk state cannot be read.
- GetMediaState no longer fails when the virtual disk is off and lun.0 does
  not exist; browsing works with the virtual disk off.
- An explicit enable for the virtual disk compares against the disk being
  configured, so it leaves a served image alone; the settings toggle keeps
  the #937 semantics.
- Restore /data write access after a failed gadget script or network switch.
- eject clears the LUN file before resetting ro/cdrom, so a refused eject
  does not change the type of the medium still being served.
- Mount loop images with MS_NOSYMFOLLOW and open files with O_NOFOLLOW.
- Accept UDF images as optical media.
- Only refuse deleting transfer temporaries while a transfer runs; sweep
  stale ones in the background, by exact name, and not on a read-only /data;
  hide them from listings.
- Cap URL downloads without Content-Length at the free space and verify the
  received length.
- Encode Content-Disposition filename* strictly per RFC 5987.
- Restore image_test.go and add tests for the above.

* fix(web): keep the file browser and transfers consistent on the storage page

- Tag a listing with the medium and path it belongs to, so entries of the
  previous directory are never shown or acted on under a new path.
- Poll the desktop storage menu only while it is open.
- Show a dedicated message for a checksum mismatch.
- Bring back the USB / SCP / TF card hints behind a help button.
- Pick up a transfer already running when the page opens; make uploads
  cancellable; track concurrent uploads separately; let a failed job be
  dismissed; show a success state and guard the progress display by task.
- Handle request errors that were left unhandled.
- Dedicated confirmation for deleting a folder recursively.
- Treat UDF images as optical, send credentials on the image download
  pre-check, translate the remaining Hungarian string.

---------

Co-authored-by: Guoguo <i@qwq.trade>
2026-10-19 09:44:30 +08:00

92 lines
2.3 KiB
Go

package utils
import (
"fmt"
"io"
"sync"
"sync/atomic"
"time"
)
// progressInterval matches the polling cadence of the web UI, so a transfer
// never reports a percentage the client has already stopped asking for.
const progressInterval = 2500 * time.Millisecond
// ProgressWriter counts the bytes written through it and reports progress to
// onProgress on a fixed ticker. Transfers that take minutes (image downloads
// and uploads, media creation) use it to feed the status endpoints the web UI
// polls.
type ProgressWriter struct {
writer io.Writer
total atomic.Int64
totalSize int64
ticker *time.Ticker
done chan struct{}
stopOnce sync.Once
onProgress func(loaded, total int64)
}
// NewProgressWriter wraps writer. totalSize is the expected number of bytes;
// when it is unknown (a chunked response, for example) no progress is
// reported. onProgress receives the bytes written so far and the total.
func NewProgressWriter(writer io.Writer, totalSize int64, onProgress func(loaded, total int64)) *ProgressWriter {
pw := &ProgressWriter{
writer: writer,
totalSize: totalSize,
onProgress: onProgress,
}
pw.startTicker()
return pw
}
func (pw *ProgressWriter) startTicker() {
pw.ticker = time.NewTicker(progressInterval)
pw.done = make(chan struct{})
go func() {
for {
select {
case <-pw.done:
return
case <-pw.ticker.C:
pw.updateProgress()
}
}
}()
}
// Stop halts the ticker. It is safe to call more than once and on a nil
// writer, so callers can defer it unconditionally.
func (pw *ProgressWriter) Stop() {
if pw == nil {
return
}
pw.stopOnce.Do(func() {
pw.ticker.Stop()
close(pw.done)
})
}
func (pw *ProgressWriter) updateProgress() {
if pw.totalSize <= 0 || pw.onProgress == nil {
return
}
pw.onProgress(pw.total.Load(), pw.totalSize)
}
// FormatPercentage renders transfer progress the way the status endpoints
// report it: values like "42.13%", and an empty string before the first tick
// or when the total size is unknown.
func FormatPercentage(loaded, total int64) string {
if loaded <= 0 || total <= 0 {
return ""
}
return fmt.Sprintf("%.2f%%", float64(loaded)/float64(total)*100)
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total.Add(int64(n))
return n, err
}