diff --git a/server/router/application.go b/server/router/application.go index 1c4d269..522e391 100644 --- a/server/router/application.go +++ b/server/router/application.go @@ -12,7 +12,9 @@ func applicationRouter(r *gin.Engine) { 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/preview", service.GetPreview) // get preview updates state api.POST("/application/preview", service.SetPreview) // set preview updates state diff --git a/server/router/download.go b/server/router/download.go index 16e9db2..4e618d3 100644 --- a/server/router/download.go +++ b/server/router/download.go @@ -14,4 +14,5 @@ func downloadRouter(r *gin.Engine) { api.POST("/download/image", service.DownloadImage) // download image api.GET("/download/image/status", service.StatusImage) // download image api.GET("/download/image/enabled", service.ImageEnabled) // download image + api.POST("/download/file", service.DownloadImageFile) // download image } diff --git a/server/service/application/update.go b/server/service/application/update.go index 9993397..0b6b0c9 100644 --- a/server/service/application/update.go +++ b/server/service/application/update.go @@ -10,6 +10,9 @@ import ( "os/exec" "sync" "time" + "path/filepath" + "strings" + "regexp" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -18,6 +21,8 @@ import ( "NanoKVM-Server/utils" ) +var sentinelPath = "/tmp/.download_in_progress" + const ( maxTries = 3 ) @@ -27,6 +32,207 @@ var ( 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 @@ -172,3 +378,54 @@ 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 +} diff --git a/server/service/application/version.go b/server/service/application/version.go index 569696e..a5ca5c8 100644 --- a/server/service/application/version.go +++ b/server/service/application/version.go @@ -48,6 +48,24 @@ func (s *Service) GetVersion(c *gin.Context) { }) } +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, + }) +} + func getLatest() (*Latest, error) { baseURL := StableURL if isPreviewEnabled() { diff --git a/server/service/download/service.go b/server/service/download/service.go index 3f63081..b0c3ef7 100644 --- a/server/service/download/service.go +++ b/server/service/download/service.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "time" + "regexp" ) type Service struct{} @@ -51,6 +52,28 @@ func (s *Service) ImageEnabled(c *gin.Context) { }) } +func isISO9660(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + + // ISO-9660 Magic "CD001" bei Offset 32769 + _, err = f.Seek(0x8001, io.SeekStart) + if err != nil { + return false, err + } + + buf := make([]byte, 5) + _, err = io.ReadFull(f, buf) + if err != nil { + return false, err + } + + return string(buf) == "CD001", nil +} + func (s *Service) StatusImage(c *gin.Context) { var rsp proto.Response @@ -93,6 +116,170 @@ func (s *Service) StatusImage(c *gin.Context) { }) } +func (s *Service) DownloadImageFile(c *gin.Context) { + var rsp proto.Response + + log.Debug("DownloadImage") + + // 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 + } + + // 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 + } + + // 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 + } + + + var lw *loggingWriter + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Error("failed to read part") + rsp.ErrRsp(c, -1, "failed to read part") + lw.stopTicker() + defer os.Remove(sentinelPath) + return + } + + if part.FormName() != "file" { + continue + } + + filename := part.FileName() + if filename == "" { + log.Error("no filename") + rsp.ErrRsp(c, -1, "no filename") + lw.stopTicker() + defer os.Remove(sentinelPath) + return + } + + filename = filepath.Base(filename) + + if filename != part.FileName() { + log.Warn("path detected in filename") + rsp.ErrRsp(c, -1, "invalid filename") + defer os.Remove(sentinelPath) + return + } + + if strings.Contains(filename, "..") { + log.Warn("path traversal attempt") + rsp.ErrRsp(c, -1, "invalid filename") + defer os.Remove(sentinelPath) + return + } + + if !strings.HasSuffix(strings.ToLower(filename), ".iso") { + rsp.ErrRsp(c, -1, "only .iso files allowed") + defer os.Remove(sentinelPath) + return + } + + valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + if !valid.MatchString(filename) { + rsp.ErrRsp(c, -1, "invalid filename") + defer os.Remove(sentinelPath) + return + } + + data, err := os.ReadFile(sentinelPath) + if err != nil { + log.Error("Read failed") + rsp.ErrRsp(c, -1, "Read failed") + lw.stopTicker() + defer os.Remove(sentinelPath) + return + } + + outPath := "/data/" + filename + out, err := os.Create(outPath) + if err != nil { + log.Error("cannot create file") + rsp.ErrRsp(c, -1, "cannot create file") + lw.stopTicker() + defer os.Remove(sentinelPath) + return + } + defer out.Close() + + if strings.Contains(string(data), "start") { + err = os.WriteFile(sentinelPath, []byte(filename), 0644) + if err != nil { + log.Error("Failed to create sentinel file") + rsp.ErrRsp(c, -1, "failed to create sentinel file") + lw.stopTicker() + defer os.Remove(outPath) + defer os.Remove(sentinelPath) + return + } + + lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength} + lw.startTicker() + } else { + if !strings.Contains(string(data), filename) { + log.Error("failed") + rsp.ErrRsp(c, -1, "failed") + lw.stopTicker() + defer os.Remove(outPath) + defer os.Remove(sentinelPath) + return + } + } + + // Direkt streamen → kein RAM-Bedarf außer kleinem Buffer + _, err = io.Copy(lw, part) + if err != nil { + log.Error("write failed") + rsp.ErrRsp(c, -1, "write failed") + lw.stopTicker() + defer os.Remove(outPath) + defer os.Remove(sentinelPath) + return + } + + ok, err := isISO9660(outPath) + if err != nil || !ok { + rsp.ErrRsp(c, -1, "file is not a valid ISO image") + lw.stopTicker() + defer os.Remove(outPath) + defer os.Remove(sentinelPath) + return + } + } + lw.stopTicker() + + rsp.OkRspWithData(c, &proto.StatusImageRsp{ + Status: "idle", + File: "", + Percentage: "", + }) + + defer os.Remove(sentinelPath) + return +} + func (s *Service) DownloadImage(c *gin.Context) { var req proto.MountImageReq var rsp proto.Response diff --git a/web/src/api/application.ts b/web/src/api/application.ts index f3192ac..cf13883 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -5,6 +5,11 @@ 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({ diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 5afeec6..61dd2c6 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -169,7 +169,10 @@ 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' + 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', @@ -314,8 +317,19 @@ 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!' + 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' }, account: { title: 'Konto', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 5c2c834..6665886 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -185,7 +185,10 @@ const en = { title: 'Image Downloader', input: 'Please enter a remote image URL', ok: 'Ok', - disabled: '/data partition is RO, so we cannot download the image' + disabled: '/data partition is RO, so we cannot download the image', + uploadbox: "Drop file here or click to select", + inputfile: "Please enter the image File", + NoISO: "No ISO" }, power: { title: 'Power', @@ -361,6 +364,18 @@ const en = { 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' + }, account: { title: 'Account', webAccount: 'Web Account Name', diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index dd21e9a..e26fa39 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -21,6 +21,8 @@ export const DownloadImage = () => { const [popoverKey, setPopoverKey] = useState(0); const inputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); const intervalId = useRef(undefined); @@ -110,6 +112,58 @@ export const DownloadImage = () => { }); } + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] ?? null; + if (!file || !file.name.toLowerCase().endsWith(".iso")) { + setStatus('failed'); + setLog(t('download.NoISO')); + return; + } + setStatus('idle'); + setLog(''); + setSelectedFile(file); + clearInterval(intervalId.current); + intervalId.current = undefined; + } + + function upload(file: File | null) { + if (!file) return; + + if (!file || !file.name.toLowerCase().endsWith(".iso")) { + setStatus('failed'); + setLog(t('download.NoISO')); + return; + } + + setStatus('in_progress'); + setLog('Downloading: ' + file.name); + + const formData = new FormData(); + formData.append("file", file); + + fetch("/api/download/file", { + method: "POST", + body: formData, + }).catch(() => { + clearInterval(intervalId.current); // Clear the interval when the download is complete or fails + setStatus('failed'); + setLog('Failed'); + }).then(() => { + clearInterval(intervalId.current); // Clear the interval when the download is complete or fails + setStatus('idle'); + setLog(''); + }); + + // Start the interval to check the download status + if (!intervalId.current) { + getDownloadStatus(); + setTimeout(() => { + intervalId.current = setInterval(getDownloadStatus, 2500); + }, 2500); + } + + } + const content = (
@@ -122,21 +176,83 @@ export const DownloadImage = () => {
{t('download.disabled')}
) : ( <> -
{t('download.input')}
-
- - +
+
{t('download.input')}
+
+ + +
+
+
+
{t('download.inputfile')}
+
+
{ + if (status === "in_progress") return; // deaktiviert + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0] ?? null; + if (!file || !file.name.toLowerCase().endsWith(".iso")) { + setStatus('failed'); + setLog(t('download.NoISO')); + return; + } + setStatus('idle'); + setLog(''); + setSelectedFile(file); + }} + onDragOver={(e) => { + if (status === "in_progress") return; // deaktiviert + e.preventDefault(); + setIsDragging(true); // Datei wird über den Bereich gezogen + }} + onDragLeave={(e) => { + if (status === "in_progress") return; // deaktiviert + e.preventDefault(); + setIsDragging(false); // Maus verlässt Bereich + }} + onClick={() => { + if (status === "in_progress") return; // deaktiviert + document.getElementById("file-upload")?.click() + }} + > + + {selectedFile ? selectedFile.name : t('download.uploadbox')} + + + +
+ +
)} diff --git a/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx b/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx new file mode 100644 index 0000000..9005ea0 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx @@ -0,0 +1,211 @@ +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(''); + const [currentVersion, setCurrentVersion] = useState(''); + const [errMsg, setErrMsg] = useState(''); + const [selectedFile, setSelectedFile] = useState(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) { + 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 ( + <> +
{t('settings.offlineupdate.title') + ", " + t('settings.about.application') + ": " + currentVersion}
+ +
+ +
+ {status === 'loading' && ( +
+ } size="large" /> +
+ )} + + {status === 'latest' && ( +
+
{t('settings.offlineupdate.inputfile')}
+
+
{ + 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() + }} + > + + {selectedFile ? selectedFile.name : t('settings.offlineupdate.uploadbox')} + + + +
+ +
+
+ )} + + {status === 'updating' && ( +
+ + {t('settings.offlineupdate.updating')} +
+ )} + + {status === 'latests' && ( + } + title={currentVersion} + subTitle={t('settings.offlineupdate.isLatest')} + extra={[ + + ]} + /> + )} + + {status === 'failed' && } + +
+ +
+
+ + ); +};