refactor: refactor offline update function

This commit is contained in:
wj-xiao
2025-12-26 15:38:05 +08:00
parent ad9eb44ad8
commit fb2adf13ee
16 changed files with 568 additions and 617 deletions

View File

@@ -11,10 +11,9 @@ func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.GET("/application/version", service.GetVersion) // get application version
api.GET("/application/currentversion", service.GetCurrentVersion) // get application current version
api.POST("/application/update", service.Update) // update application
api.POST("/application/uploadupdate", service.UploadUpdate) // upload update application
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
api.POST("/application/update/offline", service.OfflineUpdate) // update application offline
api.GET("/application/preview", service.GetPreview) // get preview updates state
api.POST("/application/preview", service.SetPreview) // set preview updates state

View File

@@ -0,0 +1,76 @@
package application
import (
"fmt"
"os"
"sync"
"NanoKVM-Server/utils"
log "github.com/sirupsen/logrus"
)
var (
mutex sync.Mutex
isUpdating bool
)
func acquireUpdateLock() bool {
mutex.Lock()
defer mutex.Unlock()
if isUpdating {
return false
}
isUpdating = true
return true
}
func releaseUpdateLock() {
mutex.Lock()
defer mutex.Unlock()
isUpdating = false
}
func installPackage(source string) error {
dir, err := utils.UnTarGz(source, CacheDir)
if err != nil {
return fmt.Errorf("failed to decompress app: %w", err)
}
if err := backupCurrentApp(); err != nil {
return err
}
if err := applyUpdate(dir); err != nil {
return err
}
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)
}
return nil
}
func backupCurrentApp() error {
if err := os.RemoveAll(BackupDir); err != nil {
return fmt.Errorf("failed to remove backup: %w", err)
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
return fmt.Errorf("failed to backup app: %w", err)
}
return nil
}
func applyUpdate(sourceDir string) error {
if err := utils.MoveFilesRecursively(sourceDir, AppDir); err != nil {
// Try to restore backup on failure
if restoreErr := utils.MoveFilesRecursively(BackupDir, AppDir); restoreErr != nil {
log.Errorf("Failed to restore backup after update failure: %v", restoreErr)
}
return fmt.Errorf("failed to move update in place: %w", err)
}
return nil
}

View File

@@ -0,0 +1,84 @@
package application
import (
"fmt"
"io"
"os"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
sentinelPath = "/tmp/.download_in_progress"
tickerInterval = 2500 * time.Millisecond
sentinelPermission = 0644
)
// progressWriter wraps an io.Writer to track and report upload progress.
type progressWriter struct {
writer io.Writer
written int64
totalSize int64
ticker *time.Ticker
done chan struct{}
}
func newProgressWriter(w io.Writer, totalSize int64) *progressWriter {
pw := &progressWriter{
writer: w,
totalSize: totalSize,
ticker: time.NewTicker(tickerInterval),
done: make(chan struct{}),
}
go pw.reportProgress()
return pw
}
func (pw *progressWriter) reportProgress() {
for {
select {
case <-pw.done:
return
case <-pw.ticker.C:
pw.updateSentinel()
}
}
}
func (pw *progressWriter) updateSentinel() {
if pw.totalSize == 0 {
return
}
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
return
}
parts := strings.SplitN(string(content), ";", 2)
if len(parts) == 0 {
return
}
percentage := float64(pw.written) / float64(pw.totalSize) * 100
newContent := fmt.Sprintf("%s;%.2f%%", parts[0], percentage)
if err := os.WriteFile(sentinelPath, []byte(newContent), sentinelPermission); err != nil {
log.Error("Failed to update sentinel file")
}
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.written += int64(n)
return n, err
}
func (pw *progressWriter) Stop() {
pw.ticker.Stop()
close(pw.done)
}

View File

@@ -8,11 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"path/filepath"
"strings"
"regexp"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -21,235 +17,18 @@ import (
"NanoKVM-Server/utils"
)
var sentinelPath = "/tmp/.download_in_progress"
const (
maxTries = 3
)
var (
updateMutex sync.Mutex
isUpdating bool
)
func (s *Service) UploadUpdate(c *gin.Context) {
var rsp proto.Response
updateMutex.Lock()
if isUpdating {
updateMutex.Unlock()
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
if err := uploadupdate(rsp, c); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("update application success")
// Sleep for a second before restarting the device
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func uploadupdate(rsp proto.Response, c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
// Set a sentinel file to mark that there is a download in progress
// This is to prevent multiple downloads at the same time
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
rsp.ErrRsp(c, -1, "download in progress")
return err
}
// Create the sentinel file
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
return err
}
// Multipart Reader direkt nutzen (keine FormFile!)
reader, err := c.Request.MultipartReader()
if err != nil {
log.Error("invalid multipart data")
rsp.ErrRsp(c, -1, "invalid multipart data")
defer os.Remove(sentinelPath)
return err
}
var lw *loggingWriter
var outPath = ""
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
lw.stopTicker()
defer os.Remove(sentinelPath)
return err
}
if part.FormName() != "file" {
continue
}
filename := part.FileName()
if filename == "" {
lw.stopTicker()
defer os.Remove(sentinelPath)
return fmt.Errorf("no filename")
}
filename = filepath.Base(filename)
if filename != part.FileName() {
defer os.Remove(sentinelPath)
return fmt.Errorf("path detected in filename")
}
if strings.Contains(filename, "..") {
log.Warn("path traversal attempt")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return fmt.Errorf("path traversal attempt")
}
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !valid.MatchString(filename) {
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return fmt.Errorf("err4")
}
data, err := os.ReadFile(sentinelPath)
if err != nil {
lw.stopTicker()
defer os.Remove(sentinelPath)
return err
}
outPath = "/data/" + filename
out, err := os.Create(outPath)
if err != nil {
lw.stopTicker()
defer os.Remove(sentinelPath)
return err
}
defer out.Close()
if strings.Contains(string(data), "start") {
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
if err != nil {
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return err
}
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
lw.startTicker()
} else {
if !strings.Contains(string(data), filename) {
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return fmt.Errorf("failed")
}
}
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
_, err = io.Copy(lw, part)
if err != nil {
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return err
}
}
lw.stopTicker()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
})
defer os.Remove(sentinelPath)
// decompress
dir, err := utils.UnTarGz(outPath, CacheDir)
if err != nil {
fmt.Errorf("decompress app failed: %s", err)
return err
}
// backup old version
if err := os.RemoveAll(BackupDir); err != nil {
fmt.Errorf("remove backup failed: %s", err)
return err
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
fmt.Errorf("backup app failed: %s", err)
return err
}
// update
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
fmt.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
fmt.Errorf("chmod failed: %s", err)
return err
}
defer os.Remove(outPath)
return nil
}
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
updateMutex.Lock()
if isUpdating {
updateMutex.Unlock()
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
defer releaseUpdateLock()
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
@@ -280,7 +59,6 @@ func update() error {
// download
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
if err := download(latest.Url, target); err != nil {
log.Errorf("download app failed: %s", err)
return err
@@ -292,34 +70,9 @@ func update() error {
return err
}
// decompress
dir, err := utils.UnTarGz(target, CacheDir)
log.Debugf("untar: %s", dir)
if err != nil {
log.Errorf("decompress app failed: %s", err)
return err
}
// backup old version
if err := os.RemoveAll(BackupDir); err != nil {
log.Errorf("remove backup failed: %s", err)
return err
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
log.Errorf("backup app failed: %s", err)
return err
}
// update
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
log.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
log.Errorf("chmod failed: %s", err)
// install
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
@@ -330,7 +83,7 @@ func download(url string, target string) (err error) {
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
time.Sleep(time.Second * 3)
}
var req *http.Request
@@ -378,54 +131,3 @@ func checksum(filePath string, expectedHash string) error {
return nil
}
type loggingWriter struct {
writer io.Writer
total int64
totalSize int64
ticker *time.Ticker
done chan bool
}
func (lw *loggingWriter) startTicker() {
lw.ticker = time.NewTicker(2500 * time.Millisecond)
lw.done = make(chan bool)
go func() {
for {
select {
case <-lw.done:
return
case <-lw.ticker.C:
lw.updateSentinel()
}
}
}()
}
func (lw *loggingWriter) stopTicker() {
lw.ticker.Stop()
lw.done <- true
}
func (lw *loggingWriter) updateSentinel() {
percentage := float64(lw.total) / float64(lw.totalSize) * 100
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
return
}
splitted := strings.Split(string(content), ";")
if len(splitted) == 0 {
return
}
err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644)
if err != nil {
log.Error("Failed to update sentinel file")
}
}
func (lw *loggingWriter) Write(p []byte) (int, error) {
n, err := lw.writer.Write(p)
lw.total += int64(n)
return n, err
}

View File

@@ -0,0 +1,176 @@
package application
import (
"fmt"
"io"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var validFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (s *Service) OfflineUpdate(c *gin.Context) {
var rsp proto.Response
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
defer releaseUpdateLock()
if err := offlineUpdate(c); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("offline update application success")
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
if err := checkDownloadInProgress(); err != nil {
return err
}
if err := createSentinelFile(); err != nil {
return err
}
defer removeSentinelFile()
reader, err := c.Request.MultipartReader()
if err != nil {
log.Errorf("Invalid multipart data: %v", err)
return fmt.Errorf("invalid multipart data: %w", err)
}
target, err := processUpload(reader, c.Request.ContentLength)
if err != nil {
log.Errorf("failed to upload install package: %v", err)
return err
}
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
return nil
}
func checkDownloadInProgress() error {
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
return fmt.Errorf("download already in progress")
}
return nil
}
func createSentinelFile() error {
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
log.Errorf("Failed to create sentinel file: %v", err)
return fmt.Errorf("failed to create sentinel file: %w", err)
}
return nil
}
func processUpload(reader *multipart.Reader, contentLength int64) (string, error) {
var outPath string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read multipart: %w", err)
}
if part.FormName() != "file" {
continue
}
outPath, err = saveUploadedFile(part, contentLength)
if err != nil {
return "", err
}
}
if outPath == "" {
return "", fmt.Errorf("no file uploaded")
}
return outPath, nil
}
func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) {
filename := part.FileName()
if filename == "" {
return "", fmt.Errorf("no filename provided")
}
if err := validateFilename(filename); err != nil {
return "", err
}
outPath := filepath.Join(CacheDir, filename)
out, err := os.Create(outPath)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
pw := newProgressWriter(out, contentLength)
defer pw.Stop()
if _, err := io.Copy(pw, part); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return outPath, nil
}
func validateFilename(filename string) error {
baseName := filepath.Base(filename)
// Check if the path contains directory components
if baseName != filename {
log.Warnf("Path detected in filename: %s", filename)
return fmt.Errorf("path detected in filename")
}
// Check for path traversal attempts
if strings.Contains(filename, "..") {
log.Warnf("Path traversal attempt: %s", filename)
return fmt.Errorf("invalid filename: path traversal detected")
}
// Validate filename characters
if !validFilenameRegex.MatchString(filename) {
log.Warnf("Invalid filename characters: %s", filename)
return fmt.Errorf("invalid filename: contains invalid characters")
}
return nil
}
func removeSentinelFile() {
_ = os.Remove(sentinelPath)
}

View File

@@ -1,7 +1,6 @@
package application
import (
"NanoKVM-Server/proto"
"encoding/json"
"fmt"
"io"
@@ -10,6 +9,8 @@ import (
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
@@ -36,33 +37,15 @@ func (s *Service) GetVersion(c *gin.Context) {
log.Debugf("current version: %s", currentVersion)
// latest version
latestVersion := ""
latest, err := getLatest()
if err != nil {
rsp.ErrRsp(c, -1, "get latest version failed")
return
if err == nil && latest != nil {
latestVersion = latest.Version
}
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latest.Version,
})
}
func (s *Service) GetCurrentVersion(c *gin.Context) {
var rsp proto.Response
// current version
currentVersion := "1.0.0"
versionFile := fmt.Sprintf("%s/version", AppDir)
if version, err := os.ReadFile(versionFile); err == nil {
currentVersion = strings.ReplaceAll(string(version), "\n", "")
}
log.Debugf("current version: %s", currentVersion)
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latestVersion,
})
}

View File

@@ -1,3 +1,3 @@
VITE_SERVER_IP=192.168.3.69
VITE_SERVER_IP=192.168.0.1
VITE_SERVER_PORT=80
VITE_WITH_CREDENTIALS=false

View File

@@ -1,15 +1,11 @@
import { http } from '@/lib/http.ts';
import { getBaseUrl } from '@/lib/service.ts';
// get application version
export function getVersion() {
return http.get('/api/application/version');
}
// get application Current version
export function getCurrentVersion() {
return http.get('/api/application/currentversion');
}
// update application to latest version
export function update() {
return http.request({
@@ -19,6 +15,16 @@ export function update() {
});
}
// offline update application
export function offlineUpdate(data: FormData) {
const baseUrl = getBaseUrl('http');
const url = `${baseUrl}/api/application/update/offline`;
return fetch(url, {
method: 'POST',
body: data
});
}
// enable/disable preview updates
export function setPreviewUpdates(enable: boolean) {
const data = {
@@ -31,4 +37,3 @@ export function setPreviewUpdates(enable: boolean) {
export function getPreviewUpdates() {
return http.get('/api/application/preview');
}

View File

@@ -14,7 +14,8 @@ const de = {
placeholderPassword2: 'Bitte Passwort erneut eingeben',
noEmptyUsername: 'Benutzername benötigt',
noEmptyPassword: 'Passwort benötigt',
noAccount: 'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen',
noAccount:
'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen',
invalidUser: 'Falscher Benutzername oder falsches Passwort',
error: 'Unerwarteter Fehler',
changePassword: 'Passwort ändern',
@@ -35,13 +36,15 @@ const de = {
change1: 'Bitte beachten Sie, dass diese Aktion folgende Passwörter ändert:',
change2: 'Web Anmelde-Passwort',
change3: 'System root Passwort (SSH Anmelde-Passwort)',
change4: 'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
change4:
'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Wi-Fi Konfiguration für NanoKVM',
success: 'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.',
success:
'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.',
failed: 'Aktion fehlgeschlagen, bitte erneut versuchen.',
confirmBtn: 'Ok',
finishBtn: 'Fertig'
@@ -52,7 +55,7 @@ const de = {
resolution: 'Auflösung',
auto: 'Automatisch',
autoTips:
"Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.",
'Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.',
fps: 'FPS',
customizeFps: 'Anpassen',
quality: 'Qualität',
@@ -89,7 +92,8 @@ const de = {
mode: 'Maus Modus',
absolute: 'Absoluter Modus',
relative: 'Relativer Modus',
requestPointer: 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
requestPointer:
'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
resetHid: 'HID zurücksetzen',
hidOnly: {
title: 'HID-Only Modus',
@@ -108,22 +112,22 @@ const de = {
cdrom: 'Binden Sie das Abbild im CD-ROM Modus ein',
mountFailed: 'Einbinden fehlgeschlagen',
mountDesc:
"In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.",
'In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.',
refresh: 'Bilder aktualisieren',
tips: {
title: 'So laden Sie Dateien hoch',
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).',
usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.',
scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.',
scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.',
scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF-Karte',
tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.',
tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).',
tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.',
tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.',
tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.'
title: 'So laden Sie Dateien hoch',
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).',
usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.',
scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.',
scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.',
scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF-Karte',
tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.',
tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).',
tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.',
tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.',
tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.'
}
},
script: {
@@ -169,10 +173,11 @@ const de = {
title: 'Systemabbild Downloader',
input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein',
ok: 'Ok',
disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
uploadbox: "Datei hier ablegen oder klicken zum Auswählen",
inputfile: "Bitte geben Sie die Datei für das Systemabbild an",
NoISO: "Keine ISO"
disabled:
'/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
uploadbox: 'Datei hier ablegen oder klicken zum Auswählen',
inputfile: 'Bitte geben Sie die Datei für das Systemabbild an',
NoISO: 'Keine ISO'
},
power: {
title: 'Power',
@@ -203,9 +208,9 @@ const de = {
hostname: 'Hostname',
hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.',
ipType: {
'Wired': 'Kabel',
'Wireless': 'Drahtlos',
'Other': 'Andere'
Wired: 'Kabel',
Wireless: 'Drahtlos',
Other: 'Andere'
}
},
appearance: {
@@ -246,7 +251,7 @@ const de = {
title: 'Swap',
disable: 'Deaktivieren',
description: 'Grösse der Swap-Datei festlegen',
tip: "Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!"
tip: 'Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!'
},
mouseJiggler: {
title: 'Mausaktivitäts-Simulator',
@@ -257,7 +262,7 @@ const de = {
},
mdns: {
description: 'mDNS-Erkennungsdienst aktivieren',
tip: "Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen"
tip: 'Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen'
},
hidOnly: 'HID-Only Mode',
disk: 'Virtuelle Festplatte',
@@ -273,7 +278,7 @@ const de = {
title: 'Tailscale',
memory: {
title: 'Speicher Optimierung',
tip: "Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.",
tip: 'Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.',
disable: 'Deaktivieren'
},
restart: 'Tailscale neu starten?',
@@ -317,19 +322,16 @@ const de = {
cancel: 'Abbrechen',
preview: 'Vorab-Versionen',
previewDesc: 'Erhalten Sie vorab Zugriff auf neue Funktionen und Verbesserungen',
previewTip: 'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!'
},
offlineupdate: {
title: 'Offline Aktualisierung',
queryFailed: 'Version konnte nicht abgefragt werden',
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
updating: 'Aktualisierung gestartet. Bitte warten...',
confirm: 'Bestätigen',
cancel: 'Abbrechen',
inputfile: "Bitte geben Sie die Datei für das Update an",
noupdatefile: "Keine Update Datei",
uploadbox: "Datei hier ablegen oder zum Auswählen klicken",
ok: 'Ok'
previewTip:
'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!',
offline: {
title: 'Offline Aktualisierung',
desc: 'Über lokales Installationspaket aktualisieren',
upload: 'Hochladen',
invalidName:
'Ungültiges Dateinamenformat. Bitte laden Sie von den GitHub-Releases herunter.',
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.'
}
},
account: {
title: 'Konto',
@@ -343,7 +345,7 @@ const de = {
}
},
error: {
title: "Wir sind auf ein Problem gestossen",
title: 'Wir sind auf ein Problem gestossen',
refresh: 'Neuladen'
},
fullscreen: {

View File

@@ -372,19 +372,14 @@ const en = {
preview: 'Preview Updates',
previewDesc: 'Get early access to new features and improvements',
previewTip:
'Please be aware that preview releases may contain bugs or incomplete functionality!'
},
offlineupdate: {
title: 'Offline Updates',
queryFailed: 'Get version failed',
updateFailed: 'Update failed. Please retry.',
updating: 'Update started. Please wait...',
confirm: 'Confirm',
cancel: 'Cancel',
inputfile: 'Please enter the Update File',
noupdatefile: 'No update file',
uploadbox: 'Drop file here or click to select',
ok: 'Ok'
'Please be aware that preview releases may contain bugs or incomplete functionality!',
offline: {
title: 'Offline Updates',
desc: 'Update through local installation package',
upload: 'Upload',
invalidName: 'Invalid filename format. Please download from GitHub releases.',
updateFailed: 'Update failed. Please retry.'
}
},
account: {
title: 'Account',

View File

@@ -339,7 +339,14 @@ const zh = {
cancel: '取消',
preview: '预览更新',
previewDesc: '率先体验即将推出的新功能和优化',
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!'
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!',
offline: {
title: '离线更新',
desc: '通过本地安装包进行更新',
upload: '上传',
invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。',
updateFailed: '更新失败,请重试'
}
},
account: {
title: '帐号',

View File

@@ -65,6 +65,9 @@ export const Settings = () => {
if (rsp.code !== 0) {
return;
}
if (!rsp.data?.current || !rsp.data?.latest) {
return;
}
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsUpdateAvailable(true);

View File

@@ -1,211 +0,0 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined, SmileOutlined } from '@ant-design/icons';
import { Button, Input, Result, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import * as api from '@/api/application.ts';
type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'latests' | 'failed';
export const OfflineUpdate = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('');
const [currentVersion, setCurrentVersion] = useState('');
const [errMsg, setErrMsg] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
checkForUpdates();
}, []);
function checkForUpdates() {
if (status === 'loading') return;
setStatus('loading');
setIsLocked(true);
api
.getCurrentVersion()
.then((rsp: any) => {
if (rsp.code !== 0 || !rsp.data) {
setStatus('failed');
setErrMsg(t('settings.offlineupdate.queryFailed'));
return;
}
setStatus('latest');
setCurrentVersion(rsp.data.current);
})
.catch(() => {
setStatus('failed');
setErrMsg(t('settings.offlineupdate.queryFailed'));
});
setIsLocked(false);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) {
setStatus('failed');
setErrMsg(t('settings.offlineupdate.noupdatefile'));
return;
}
setSelectedFile(file);
}
function upload(file: File | null) {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".tar.gz")) {
setStatus('failed');
setErrMsg(t('settings.offlineupdate.noupdatefile'));
return;
}
setStatus('updating');
setErrMsg('');
const formData = new FormData();
formData.append("file", file);
fetch("/api/application/uploadupdate", {
method: "POST",
body: formData,
})
.then((rsp: Response) => {
// Prüfen ob HTTP OK
if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`);
return rsp.json(); // JSON-Payload parsen
})
.then((rspj: any) => {
// Jetzt rspj ist das tatsächliche JSON
if (rspj.code !== 0 || !rspj.data) {
setStatus('failed');
setErrMsg(rspj.msg || 'Unknown error');
console.log(rspj);
return;
}
checkForUpdates();
})
.catch((err: any) => {
setStatus('failed');
setErrMsg(err?.message || 'Failed');
});
}
return (
<>
<div className="text-base font-bold">{t('settings.offlineupdate.title') + ", " + t('settings.about.application') + ": " + currentVersion}</div>
<div className="my-[20px] h-px bg-neutral-500/10" />
<div className="flex min-h-[150px] flex-col justify-between">
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
{status === 'latest' && (
<div>
<div className="pb-1 text-neutral-500">{t('settings.offlineupdate.inputfile')}</div>
<div className="flex items-center space-x-1">
<div
className={clsx(
"flex flex-col items-center justify-center w-full h-20 border-2 border-solid rounded-xl transition css-9118ya ant-input-outlined cursor-pointer border-color hover:bg-neutral-500",
isDragging ? "bg-neutral-500 border-blue-500" : ""
)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) {
setStatus('failed');
setErrMsg(t('settings.offlineupdate.noupdatefile'));
return;
}
setStatus('latest');
setErrMsg('');
setSelectedFile(file);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true); // Datei wird über den Bereich gezogen
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false); // Maus verlässt Bereich
}}
onClick={() => {
document.getElementById("file-upload")?.click()
}}
>
<span className="text-neutral-100 text-sm p-1">
{selectedFile ? selectedFile.name : t('settings.offlineupdate.uploadbox')}
</span>
<Input
id="file-upload"
type="file"
onChange={handleFileChange}
className="hidden"
/>
</div>
<Button
type="primary"
className="h-20 border-2 w-[5rem]"
onClick={() => upload(selectedFile)}
disabled={!selectedFile}
>
{t('settings.offlineupdate.ok')}
</Button>
</div>
</div>
)}
{status === 'updating' && (
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
<Spin size="large" />
<span className="text-blue-600">{t('settings.offlineupdate.updating')}</span>
</div>
)}
{status === 'latests' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.offlineupdate.isLatest')}
extra={[
<Button key="confirm" onClick={checkForUpdates}>
{t('settings.offlineupdate.title')}
</Button>
]}
/>
)}
{status === 'failed' && <Result subTitle={errMsg} />}
<div className="flex justify-center">
<Button
type="link"
size="small"
href="https://github.com/sipeed/NanoKVM/blob/main/CHANGELOG.md"
target="_blank"
>
CHANGELOG
</Button>
</div>
</div>
</>
);
};

View File

@@ -6,18 +6,19 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import { Offline } from './offline.tsx';
import { Preview } from './preview.tsx';
type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('');
const [status, setStatus] = useState('');
const [currentVersion, setCurrentVersion] = useState('');
const [latestVersion, setLatestVersion] = useState('');
const [errMsg, setErrMsg] = useState('');
@@ -40,10 +41,14 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
}
setCurrentVersion(rsp.data.current);
setLatestVersion(rsp.data.latest);
const isLatest = semver.gte(rsp.data.current, rsp.data.latest);
setStatus(isLatest ? 'latest' : 'outdated');
if (rsp.data?.latest) {
setLatestVersion(rsp.data.latest);
const isLatest = semver.gte(rsp.data.current, rsp.data.latest);
setStatus(isLatest ? 'latest' : 'outdated');
} else {
setStatus('latest');
}
})
.catch(() => {
setStatus('failed');
@@ -81,10 +86,15 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<Divider className="opacity-50" />
<Preview />
<Offline
status={status}
setStatus={setStatus}
setIsLocked={setIsLocked}
setErrMsg={setErrMsg}
/>
<Divider className="opacity-50" />
<Divider style={{ margin: '50px 0 10px 0', opacity: 0.5 }} />
<div className="flex min-h-[400px] flex-col justify-between">
<div className="flex min-h-[320px] flex-col justify-between">
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
@@ -94,7 +104,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
{status === 'updating' && (
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
<Spin size="large" />
<span className="text-blue-600">{t('settings.update.updating')}</span>
<span className="text-neutral-500">{t('settings.update.updating')}</span>
</div>
)}

View File

@@ -0,0 +1,120 @@
import { useRef } from 'react';
import { Button } from 'antd';
import { ExternalLinkIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/application.ts';
interface UpdateProps {
status: string;
setStatus: (status: string) => void;
setIsLocked: (isClosable: boolean) => void;
setErrMsg: (msg: string) => void;
}
export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdateProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement | null>(null);
function handleClick() {
inputRef.current?.click();
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) {
return;
}
if (!validateFilename(file.name)) {
setStatus('failed');
setErrMsg(t('settings.update.offline.invalidName'));
return;
}
upload(file);
}
function upload(file: File | null) {
if (!file) return;
if (!validateFilename(file.name)) {
setStatus('failed');
setErrMsg(t('settings.update.offline.invalidName'));
return;
}
if (status === 'loading' || status === 'updating') {
return;
}
setIsLocked(true);
setStatus('updating');
setErrMsg('');
const formData = new FormData();
formData.append('file', file);
api
.offlineUpdate(formData)
.then((rsp: Response) => {
// Prüfen ob HTTP OK
if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`);
return rsp.json(); // JSON-Payload parsen
})
.then((rspj: any) => {
// Jetzt rspj ist das tatsächliche JSON
if (rspj.code !== 0 || !rspj.data) {
setStatus('failed');
setErrMsg(rspj.msg || t('settings.update.offline.updateFailed'));
console.log(rspj);
return;
}
})
.finally(() => {
setTimeout(() => {
setIsLocked(false);
setErrMsg('');
window.location.reload();
}, 12000);
});
}
function validateFilename(filename: string) {
const regex: RegExp = /^nanokvm_\d+\.\d+\.\d+\.tar\.gz$/;
return regex.test(filename);
}
return (
<>
<div className="mt-8 flex items-center justify-between">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>{t('settings.update.offline.title')}</span>
<a
className="flex items-center text-neutral-500 hover:text-blue-500"
href="https://github.com/sipeed/NanoKVM/releases"
target="_blank"
>
<ExternalLinkIcon size={15} />
</a>
</div>
<span className="text-xs text-neutral-500">{t('settings.update.offline.desc')}</span>
</div>
<input
id="file-upload"
ref={inputRef}
type="file"
onChange={handleFileChange}
className="hidden"
/>
<Button onClick={handleClick}>{t('settings.update.offline.upload')}</Button>
</div>
</>
);
};

View File

@@ -63,7 +63,7 @@ export const Preview = () => {
<Tooltip
title={t('settings.update.previewTip')}
className="cursor-pointer"
placement="right"
placement="top"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon className="text-neutral-500" size={15} />