Browse Source

Add CRC32 chunk checks & retry for uploads

Server and client changes to add per-chunk CRC32 verification, retry handling, and final file checksum validation for low-memory WebSocket uploads.

- src/file_system.go: import hash/crc32; implement per-chunk metadata parsing (JSON {index,checksum}), verify chunk CRC32 before writing, maintain running full-file CRC32, request client retry via {"retryChunk":N} on mismatch, and validate final file checksum on done. Also fix several JSON text responses and ensure tmp cleanup on errors.
- src/web/Recorder/index.html: add CRC32 table/helpers, implement ordered chunk queue, send metadata frame then binary frame, maintain running CRC32, per-chunk ACK timeout and retry logic, and send final done message with full-file checksum. Update recorder UI status handling.
- src/web/SystemAO/file_system/file_explorer.html: add CRC32 helpers, per-chunk timeouts and retry logic, running CRC tracking, upload retry map and retry UI/button, improved progress handling and error states, and JSON parsing fixes.
- src/web/desktop.html: add CRC32 helpers and per-chunk retry/timeouts, compute and send final checksum, handle server retry requests, plus various UI/UX tweaks for window corner resizing and close-button interaction.

These changes improve upload integrity and robustness over unstable networks by detecting chunk corruption, requesting retransmit of bad chunks, and validating the complete file on the server.
Toby Chui 3 weeks ago
parent
commit
e9da31f31c
4 changed files with 603 additions and 168 deletions
  1. 108 44
      src/file_system.go
  2. 141 4
      src/web/Recorder/index.html
  3. 171 66
      src/web/SystemAO/file_system/file_explorer.html
  4. 183 54
      src/web/desktop.html

+ 108 - 44
src/file_system.go

@@ -5,6 +5,7 @@ import (
 	"encoding/hex"
 	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
+	"hash/crc32"
 	"io"
 	"io"
 	"io/fs"
 	"io/fs"
 	"log"
 	"log"
@@ -498,6 +499,16 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 	}()
 	}()
 
 
 	totalFileSize := int64(0)
 	totalFileSize := int64(0)
+
+	// Full-file CRC32 hasher (IEEE polynomial, matches JS crc32Table implementation)
+	fileCRC32Hasher := crc32.NewIEEE()
+
+	// Per-chunk state machine:
+	// The client sends a text metadata frame {"index":N,"checksum":"hex"} before each binary frame.
+	var pendingChunkIndex int
+	var pendingChunkChecksum string
+	expectingBinary := false
+
 	for {
 	for {
 		mt, message, err := c.ReadMessage()
 		mt, message, err := c.ReadMessage()
 		if err != nil {
 		if err != nil {
@@ -510,20 +521,89 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 			} else {
 			} else {
 				os.RemoveAll(uploadFolder)
 				os.RemoveAll(uploadFolder)
 			}
 			}
-
 			return
 			return
 		}
 		}
-		//The mt should be 2 = binary for file upload and 1 for control syntax
+
 		if mt == 1 {
 		if mt == 1 {
-			msg := strings.TrimSpace(string(message))
-			if msg == "done" {
-				//Start the merging process
-				break
+			// Text frame – either chunk metadata or done signal
+			textMsg := strings.TrimSpace(string(message))
+
+			if !expectingBinary {
+				// Check if this is the done signal
+				var doneSignal struct {
+					Done         bool   `json:"done"`
+					TotalChunks  int    `json:"totalChunks"`
+					FileChecksum string `json:"fileChecksum"`
+				}
+				if jsonErr := json.Unmarshal([]byte(textMsg), &doneSignal); jsonErr == nil && doneSignal.Done {
+					// Verify the full-file CRC32 before merging
+					if doneSignal.FileChecksum != "" {
+						computedSum := fileCRC32Hasher.Sum32()
+						computedSumBytes := []byte{byte(computedSum >> 24), byte(computedSum >> 16), byte(computedSum >> 8), byte(computedSum)}
+						computedHex := hex.EncodeToString(computedSumBytes)
+						if doneSignal.FileChecksum != computedHex {
+							systemWideLogger.PrintAndLog("File System", "Upload file checksum mismatch: client="+doneSignal.FileChecksum+" server="+computedHex, nil)
+							c.WriteMessage(1, []byte(`{"error":"File integrity check failed: full-file checksum mismatch"}`))
+							c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
+							time.Sleep(1 * time.Second)
+							c.Close()
+							if isHugeFile {
+								fshAbs.RemoveAll(uploadFolder)
+							} else {
+								os.RemoveAll(uploadFolder)
+							}
+							return
+						}
+					}
+					// Checksum verified – proceed to merge
+					break
+				}
+
+				// Parse as chunk metadata
+				var meta struct {
+					Index    int    `json:"index"`
+					Checksum string `json:"checksum"`
+				}
+				if jsonErr := json.Unmarshal([]byte(textMsg), &meta); jsonErr != nil {
+					systemWideLogger.PrintAndLog("File System", "Invalid chunk metadata received: "+textMsg, jsonErr)
+					continue
+				}
+				pendingChunkIndex = meta.Index
+				pendingChunkChecksum = meta.Checksum
+				expectingBinary = true
 			}
 			}
+
 		} else if mt == 2 {
 		} else if mt == 2 {
-			//File block. Save it to tmp folder
-			chunkFilepath := filepath.Join(uploadFolder, "upld_"+strconv.Itoa(blockCounter))
-			chunkName = append(chunkName, chunkFilepath)
+			// Binary frame – the chunk data that follows a metadata frame
+			if !expectingBinary {
+				systemWideLogger.PrintAndLog("File System", "Received binary chunk without preceding metadata, ignoring", nil)
+				continue
+			}
+			expectingBinary = false
+
+			// Verify chunk CRC32
+			chunkSum := crc32.ChecksumIEEE(message)
+			chunkSumBytes := []byte{byte(chunkSum >> 24), byte(chunkSum >> 16), byte(chunkSum >> 8), byte(chunkSum)}
+			chunkHex := hex.EncodeToString(chunkSumBytes)
+			if pendingChunkChecksum != "" && pendingChunkChecksum != chunkHex {
+				// CRC32 mismatch – ask the client to re-send this chunk
+				systemWideLogger.PrintAndLog("File System", "Chunk "+strconv.Itoa(pendingChunkIndex)+" CRC32 mismatch: expected "+pendingChunkChecksum+" got "+chunkHex, nil)
+				retryMsg, _ := json.Marshal(map[string]int{"retryChunk": pendingChunkIndex})
+				c.WriteMessage(1, retryMsg)
+				// Reset state; client will re-send the metadata+binary for this chunk
+				continue
+			}
+
+			// Chunk verified – write to tmp folder.
+			// Use pendingChunkIndex as the canonical filename so that a retry overwrites
+			// the previous (corrupted) attempt rather than creating a duplicate entry.
+			chunkFilepath := filepath.Join(uploadFolder, "upld_"+strconv.Itoa(pendingChunkIndex))
+			if pendingChunkIndex == blockCounter {
+				// First time this chunk index is successfully received
+				chunkName = append(chunkName, chunkFilepath)
+				blockCounter++
+			}
+
 			var writeErr error
 			var writeErr error
 			if isHugeFile {
 			if isHugeFile {
 				writeErr = fshAbs.WriteFile(chunkFilepath, message, 0700)
 				writeErr = fshAbs.WriteFile(chunkFilepath, message, 0700)
@@ -532,16 +612,11 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 			}
 			}
 
 
 			if writeErr != nil {
 			if writeErr != nil {
-				//Unable to write block. Is the tmp folder fulled?
-				systemWideLogger.PrintAndLog("File System", "Upload chunk write failed: "+err.Error(), err)
-				c.WriteMessage(1, []byte(`{\"error\":\"Write file chunk to disk failed\"}`))
-
-				//Close the connection
+				systemWideLogger.PrintAndLog("File System", "Upload chunk write failed: "+writeErr.Error(), writeErr)
+				c.WriteMessage(1, []byte(`{"error":"Write file chunk to disk failed"}`))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				time.Sleep(1 * time.Second)
 				time.Sleep(1 * time.Second)
 				c.Close()
 				c.Close()
-
-				//Clear the tmp files
 				if isHugeFile {
 				if isHugeFile {
 					fshAbs.RemoveAll(uploadFolder)
 					fshAbs.RemoveAll(uploadFolder)
 				} else {
 				} else {
@@ -550,22 +625,18 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 				return
 				return
 			}
 			}
 
 
-			//Update the last upload chunk time
-			lastChunkArrivalTime = time.Now().Unix()
+			// Update running full-file CRC32 with the verified chunk data
+			fileCRC32Hasher.Write(message)
 
 
-			//Check if the file size is too big
+			// Update timing and quota tracking
+			lastChunkArrivalTime = time.Now().Unix()
 			totalFileSize += int64(len(message))
 			totalFileSize += int64(len(message))
 
 
 			if totalFileSize > max_upload_size {
 			if totalFileSize > max_upload_size {
-				//File too big
-				c.WriteMessage(1, []byte(`{\"error\":\"File size too large\"}`))
-
-				//Close the connection
+				c.WriteMessage(1, []byte(`{"error":"File size too large"}`))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				time.Sleep(1 * time.Second)
 				time.Sleep(1 * time.Second)
 				c.Close()
 				c.Close()
-
-				//Clear the tmp files
 				if isHugeFile {
 				if isHugeFile {
 					fshAbs.RemoveAll(uploadFolder)
 					fshAbs.RemoveAll(uploadFolder)
 				} else {
 				} else {
@@ -573,28 +644,21 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 				}
 				}
 				return
 				return
 			} else if !userinfo.StorageQuota.HaveSpace(totalFileSize) {
 			} else if !userinfo.StorageQuota.HaveSpace(totalFileSize) {
-				//Quota exceeded
-				c.WriteMessage(1, []byte(`{\"error\":\"User Storage Quota Exceeded\"}`))
-
-				//Close the connection
+				c.WriteMessage(1, []byte(`{"error":"User Storage Quota Exceeded"}`))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 				time.Sleep(1 * time.Second)
 				time.Sleep(1 * time.Second)
 				c.Close()
 				c.Close()
-
-				//Clear the tmp files
 				if isHugeFile {
 				if isHugeFile {
 					fshAbs.RemoveAll(uploadFolder)
 					fshAbs.RemoveAll(uploadFolder)
 				} else {
 				} else {
 					os.RemoveAll(uploadFolder)
 					os.RemoveAll(uploadFolder)
 				}
 				}
+				return
 			}
 			}
-			blockCounter++
 
 
-			//Request client to send the next chunk
+			// Acknowledge the chunk; client will send the next metadata+binary pair
 			c.WriteMessage(1, []byte("next"))
 			c.WriteMessage(1, []byte("next"))
-
 		}
 		}
-		//systemWideLogger.PrintAndLog("File System", ("recv:", len(message), "type", mt)
 	}
 	}
 
 
 	//Try to decode the location if possible
 	//Try to decode the location if possible
@@ -621,7 +685,7 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 
 
 	if err != nil {
 	if err != nil {
 		systemWideLogger.PrintAndLog("File System", "Failed to open file:"+err.Error(), err)
 		systemWideLogger.PrintAndLog("File System", "Failed to open file:"+err.Error(), err)
-		c.WriteMessage(1, []byte(`{\"error\":\"Failed to open destination file\"}`))
+		c.WriteMessage(1, []byte(`{"error":"Failed to open destination file"}`))
 		c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 		c.WriteControl(8, []byte{}, time.Now().Add(time.Second))
 		time.Sleep(1 * time.Second)
 		time.Sleep(1 * time.Second)
 		c.Close()
 		c.Close()
@@ -638,7 +702,7 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 
 
 		if err != nil {
 		if err != nil {
 			systemWideLogger.PrintAndLog("File System", "Failed to open Source Chunk"+filesrc+" with error "+err.Error(), err)
 			systemWideLogger.PrintAndLog("File System", "Failed to open Source Chunk"+filesrc+" with error "+err.Error(), err)
-			c.WriteMessage(1, []byte(`{\"error\":\"Failed to open Source Chunk\"}`))
+			c.WriteMessage(1, []byte(`{"error":"Failed to open Source Chunk"}`))
 			return
 			return
 		}
 		}
 
 
@@ -655,7 +719,7 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 
 
 		//Write to websocket for the percentage of upload is written fro tmp to dest
 		//Write to websocket for the percentage of upload is written fro tmp to dest
 		moveProg := strconv.Itoa(int(math.Round(float64(counter)/float64(len(chunkName))*100))) + "%"
 		moveProg := strconv.Itoa(int(math.Round(float64(counter)/float64(len(chunkName))*100))) + "%"
-		c.WriteMessage(1, []byte(`{\"move\":\"`+moveProg+`"}`))
+		c.WriteMessage(1, []byte(`{"move":"`+moveProg+`"}`))
 	}
 	}
 
 
 	out.Close()
 	out.Close()
@@ -671,11 +735,11 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 	if err != nil {
 		// Could not obtain stat, handle error
 		// Could not obtain stat, handle error
 		systemWideLogger.PrintAndLog("File System", "Failed to validate uploaded file: "+mergeFileLocation+". Error Message: "+err.Error(), err)
 		systemWideLogger.PrintAndLog("File System", "Failed to validate uploaded file: "+mergeFileLocation+". Error Message: "+err.Error(), err)
-		c.WriteMessage(1, []byte(`{\"error\":\"Failed to validate uploaded file\"}`))
+		c.WriteMessage(1, []byte(`{"error":"Failed to validate uploaded file"}`))
 		return
 		return
 	}
 	}
 	if !userinfo.StorageQuota.HaveSpace(fi.Size()) {
 	if !userinfo.StorageQuota.HaveSpace(fi.Size()) {
-		c.WriteMessage(1, []byte(`{\"error\":\"User Storage Quota Exceeded\"}`))
+		c.WriteMessage(1, []byte(`{"error":"User Storage Quota Exceeded"}`))
 		if fsh.RequireBuffer {
 		if fsh.RequireBuffer {
 			os.RemoveAll(mergeFileLocation)
 			os.RemoveAll(mergeFileLocation)
 		} else {
 		} else {
@@ -690,7 +754,7 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 		f, err := os.Open(mergeFileLocation)
 		f, err := os.Open(mergeFileLocation)
 		if err != nil {
 		if err != nil {
 			systemWideLogger.PrintAndLog("File System", "Failed to open buffered file at "+mergeFileLocation+" with error "+err.Error(), err)
 			systemWideLogger.PrintAndLog("File System", "Failed to open buffered file at "+mergeFileLocation+" with error "+err.Error(), err)
-			c.WriteMessage(1, []byte(`{\"error\":\"Failed to open buffered object\"}`))
+			c.WriteMessage(1, []byte(`{"error":"Failed to open buffered object"}`))
 			f.Close()
 			f.Close()
 			return
 			return
 		}
 		}
@@ -698,7 +762,7 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 		err = fsh.FileSystemAbstraction.WriteStream(decodedUploadLocation, f, 0775)
 		err = fsh.FileSystemAbstraction.WriteStream(decodedUploadLocation, f, 0775)
 		if err != nil {
 		if err != nil {
 			systemWideLogger.PrintAndLog("File System", "Failed to write to file system: "+fsh.UUID+" with error "+err.Error(), err)
 			systemWideLogger.PrintAndLog("File System", "Failed to write to file system: "+fsh.UUID+" with error "+err.Error(), err)
-			c.WriteMessage(1, []byte(`{\"error\":\"Failed to upload to remote file system\"}`))
+			c.WriteMessage(1, []byte(`{"error":"Failed to upload to remote file system"}`))
 			f.Close()
 			f.Close()
 			return
 			return
 		}
 		}
@@ -1425,7 +1489,7 @@ func system_fs_handleWebSocketOpr(w http.ResponseWriter, r *http.Request) {
 
 
 	//Send over the oprId for this file operation for tracking
 	//Send over the oprId for this file operation for tracking
 	time.Sleep(300 * time.Millisecond)
 	time.Sleep(300 * time.Millisecond)
-	c.WriteMessage(1, []byte("{\"oprid\":\""+oprId+"\"}"))
+	c.WriteMessage(1, []byte(`{"oprid":"`+oprId+`"}`))
 
 
 	type ProgressUpdate struct {
 	type ProgressUpdate struct {
 		LatestFile string
 		LatestFile string
@@ -2228,7 +2292,7 @@ func system_fs_handleUserPreference(w http.ResponseWriter, r *http.Request) {
 		result := ""
 		result := ""
 		err := sysdb.Read("fs", "pref/"+key+"/"+username, &result)
 		err := sysdb.Read("fs", "pref/"+key+"/"+username, &result)
 		if err != nil {
 		if err != nil {
-			utils.SendJSONResponse(w, "{\"error\":\"Key not found.\"}")
+			utils.SendJSONResponse(w, `{"error":"Key not found."}`)
 			return
 			return
 		}
 		}
 		utils.SendTextResponse(w, result)
 		utils.SendTextResponse(w, result)

+ 141 - 4
src/web/Recorder/index.html

@@ -322,6 +322,37 @@
             var websocket;
             var websocket;
             var videCaptureRecorder;
             var videCaptureRecorder;
 
 
+            // CRC32 lookup table and helpers (IEEE polynomial, matches Go's hash/crc32 package)
+            const crc32Table = (() => {
+                const t = new Uint32Array(256);
+                for (let i = 0; i < 256; i++) {
+                    let c = i;
+                    for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
+                    t[i] = c;
+                }
+                return t;
+            })();
+            function crc32UpdateState(state, bytes) {
+                for (let i = 0; i < bytes.length; i++)
+                    state = (state >>> 8) ^ crc32Table[(state ^ bytes[i]) & 0xFF];
+                return state;
+            }
+            function crc32Hex(bytes) {
+                return ((crc32UpdateState(0xFFFFFFFF, bytes) ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+            }
+
+            // Chunk queue state for ordered, ack-gated WebSocket upload
+            const WS_CHUNK_TIMEOUT_MS = 30000;
+            const WS_MAX_CHUNK_RETRIES = 3;
+            var wsChunkQueue        = [];
+            var wsIsProcessingChunk = false;
+            var wsChunkIndex        = 0;
+            var wsRunningCRC32State = 0xFFFFFFFF;
+            var wsChunkCRC32Committed = {};
+            var wsChunkRetryCount   = 0;
+            var wsChunkTimeoutTimer = null;
+            var wsRecordingStopped  = false;
+
             function getWebsocketUploadEndpoint(){
             function getWebsocketUploadEndpoint(){
                 return getWebSocketEndpoint() + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(getVideoFilename()) + "&path=" + encodeURIComponent($("#saveFolder").val());
                 return getWebSocketEndpoint() + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(getVideoFilename()) + "&path=" + encodeURIComponent($("#saveFolder").val());
             }
             }
@@ -372,8 +403,54 @@
 
 
             function startVideoCapture() {
             function startVideoCapture() {
                 this.disabled = true;
                 this.disabled = true;
+
+                // Reset all chunk queue state for a fresh recording session
+                wsChunkQueue          = [];
+                wsIsProcessingChunk   = false;
+                wsChunkIndex          = 0;
+                wsRunningCRC32State   = 0xFFFFFFFF;
+                wsChunkCRC32Committed = {};
+                wsChunkRetryCount     = 0;
+                wsChunkTimeoutTimer   = null;
+                wsRecordingStopped    = false;
+
                 websocket = new WebSocket(getWebsocketUploadEndpoint());
                 websocket = new WebSocket(getWebsocketUploadEndpoint());
-                //websocket.binaryType = 'blob';
+
+                websocket.onmessage = async function(event) {
+                    var data = event.data;
+                    if (data === "next") {
+                        clearTimeout(wsChunkTimeoutTimer);
+                        wsChunkRetryCount = 0;
+                        wsChunkQueue.shift(); // remove the acknowledged chunk
+                        wsIsProcessingChunk = false;
+                        processNextWsChunk();
+                    } else if (data === "OK") {
+                        updateStatus("Capture Saved");
+                    } else {
+                        try {
+                            var resp = JSON.parse(data);
+                            if (resp.retryChunk !== undefined) {
+                                clearTimeout(wsChunkTimeoutTimer);
+                                if (wsChunkRetryCount < WS_MAX_CHUNK_RETRIES && wsChunkQueue.length > 0) {
+                                    wsChunkRetryCount++;
+                                    console.warn("[Recorder] Server requested retry for chunk " + resp.retryChunk);
+                                    let top = wsChunkQueue[0];
+                                    await sendWsChunk(top.index, top.blob);
+                                }
+                            } else if (resp.error !== undefined) {
+                                updateStatus("Upload Failed: " + resp.error);
+                            }
+                        } catch(ex) {
+                            console.log(data, ex);
+                        }
+                    }
+                };
+
+                websocket.onerror = function(error) {
+                    console.error("[Recorder] WebSocket error:", error);
+                    clearTimeout(wsChunkTimeoutTimer);
+                    updateStatus("Upload Error – check console");
+                };
 
 
                 captureCamera(function(camera) {
                 captureCamera(function(camera) {
 
 
@@ -383,7 +460,9 @@
                         timeSlice: 100,
                         timeSlice: 100,
                         getNativeBlob: true,
                         getNativeBlob: true,
                         ondataavailable: function(blob) {
                         ondataavailable: function(blob) {
-                            websocket.send(blob);
+                            var currentIndex = wsChunkIndex++;
+                            wsChunkQueue.push({index: currentIndex, blob: blob});
+                            processNextWsChunk();
                         }
                         }
                     });
                     });
 
 
@@ -403,12 +482,70 @@
                 });
                 });
             }
             }
 
 
+            async function sendWsChunk(id, blob) {
+                let arrayBuffer;
+                try {
+                    arrayBuffer = await blob.arrayBuffer();
+                } catch(e) {
+                    console.error("[Recorder] Failed to read chunk " + id + ": " + e);
+                    wsIsProcessingChunk = false;
+                    return;
+                }
+
+                let bytes = new Uint8Array(arrayBuffer);
+
+                // Update full-file running CRC32 only on first attempt per chunk
+                if (!wsChunkCRC32Committed[id]) {
+                    wsRunningCRC32State = crc32UpdateState(wsRunningCRC32State, bytes);
+                    wsChunkCRC32Committed[id] = true;
+                }
+
+                let chunkCRCHex = crc32Hex(bytes);
+                websocket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
+                websocket.send(arrayBuffer);
+
+                // Start ack timeout; retry on expiry
+                clearTimeout(wsChunkTimeoutTimer);
+                wsChunkTimeoutTimer = setTimeout(function() {
+                    if (wsChunkRetryCount < WS_MAX_CHUNK_RETRIES) {
+                        wsChunkRetryCount++;
+                        console.warn("[Recorder] Chunk " + id + " ACK timeout – retry " + wsChunkRetryCount + "/" + WS_MAX_CHUNK_RETRIES);
+                        sendWsChunk(id, blob);
+                    } else {
+                        console.error("[Recorder] Chunk " + id + " failed after max retries");
+                        wsIsProcessingChunk = false;
+                    }
+                }, WS_CHUNK_TIMEOUT_MS);
+            }
+
+            function processNextWsChunk() {
+                if (wsIsProcessingChunk || wsChunkQueue.length === 0) {
+                    // If recording has stopped and all chunks are drained, send the done signal
+                    if (wsRecordingStopped && !wsIsProcessingChunk && wsChunkQueue.length === 0) {
+                        sendWsDoneSignal();
+                    }
+                    return;
+                }
+                wsIsProcessingChunk = true;
+                var top = wsChunkQueue[0]; // peek – shift happens only after "next" ack
+                sendWsChunk(top.index, top.blob);
+            }
+
+            function sendWsDoneSignal() {
+                var finalCRC32Hex = ((wsRunningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+                websocket.send(JSON.stringify({done: true, totalChunks: wsChunkIndex, fileChecksum: finalCRC32Hex}));
+                updateStatus("Saving...");
+            }
 
 
             function stopVideoCapture(){
             function stopVideoCapture(){
                 videCaptureRecorder.stopRecording(stopRecordingCallback);
                 videCaptureRecorder.stopRecording(stopRecordingCallback);
-                websocket.send("done");
+                // Don't send "done" immediately – wait for the chunk queue to drain
+                wsRecordingStopped = true;
+                if (!wsIsProcessingChunk && wsChunkQueue.length === 0) {
+                    sendWsDoneSignal();
+                }
                 clearInterval(recordTimer);
                 clearInterval(recordTimer);
-                updateStatus("Capturing Stopped");
+                updateStatus("Capturing Stopped – Saving...");
                 $(".item.tab").removeClass("disabled");
                 $(".item.tab").removeClass("disabled");
                 $("#vidstop").addClass("disabled");
                 $("#vidstop").addClass("disabled");
                 $("#capture").removeClass("disabled");
                 $("#capture").removeClass("disabled");

+ 171 - 66
src/web/SystemAO/file_system/file_explorer.html

@@ -506,7 +506,6 @@
             <div class="uploadList" id="uploadProgressList">
             <div class="uploadList" id="uploadProgressList">
               
               
             </div>
             </div>
-            <br>
         </div>
         </div>
 
 
         <!-- Confirm Exit -->
         <!-- Confirm Exit -->
@@ -573,10 +572,34 @@
             let uploadingFileCount = 0;
             let uploadingFileCount = 0;
             let maxConcurrentUpload = 4; //Maxmium number of oncurrent upload
             let maxConcurrentUpload = 4; //Maxmium number of oncurrent upload
             let uploadPendingList = []; //Upload pending queue for mass upoad
             let uploadPendingList = []; //Upload pending queue for mass upoad
+            let uploadRetryMap = new Map(); //taskUUID -> {file, targetDir} for WebSocket upload retry
             let lowMemoryMode = true;   //Upload with low memory mode channel
             let lowMemoryMode = true;   //Upload with low memory mode channel
             let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
             let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
             let uploadFileChunkSize = 1024 * 512; //512KB, 4MB not working quite well on slow network
             let uploadFileChunkSize = 1024 * 512; //512KB, 4MB not working quite well on slow network
             let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
             let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
+            const CHUNK_TIMEOUT_MS = 30000; //30s timeout waiting for server "next" ack before retrying a chunk
+            const MAX_CHUNK_RETRIES = 3;    //Maximum retries per individual chunk before failing the upload
+
+            //CRC32 lookup table and helpers for chunk/file integrity verification
+            const crc32Table = (() => {
+                const t = new Uint32Array(256);
+                for (let i = 0; i < 256; i++) {
+                    let c = i;
+                    for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
+                    t[i] = c;
+                }
+                return t;
+            })();
+            //Update a running CRC32 state with new bytes (does not finalize)
+            function crc32UpdateState(state, bytes) {
+                for (let i = 0; i < bytes.length; i++)
+                    state = (state >>> 8) ^ crc32Table[(state ^ bytes[i]) & 0xFF];
+                return state;
+            }
+            //Compute standalone CRC32 of a Uint8Array and return hex string
+            function crc32Hex(bytes) {
+                return ((crc32UpdateState(0xFFFFFFFF, bytes) ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+            }
 
 
             //File Sharing related
             //File Sharing related
             let shareEditingObject = "";
             let shareEditingObject = "";
@@ -4711,23 +4734,88 @@
 
 
                 let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(filename) + "&path=" + encodeURIComponent(uploadDir) + hugeFileMode);
                 let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(filename) + "&path=" + encodeURIComponent(uploadDir) + hugeFileMode);
                 let currentSendingIndex = 0;
                 let currentSendingIndex = 0;
-                let chunks = Math.ceil(file.size/uploadFileChunkSize,uploadFileChunkSize);
-                
-                //Define a function for sending a particular chunk
-                function sendChunk(id){
-                    var offsetStart = id*uploadFileChunkSize;
-                    var offsetEnd = id*uploadFileChunkSize + uploadFileChunkSize;
-                    var thisblob = file.slice(offsetStart,offsetEnd);
-                    socket.send(thisblob);
-                    //console.log(id + "/" + chunks);
-
-                    //Update progress to first percentage
-                    var progress = id / (chunks-1) * 100.0;
-                    if (progress > 100){
-                        progress = 100;
+                let chunks = Math.ceil(file.size/uploadFileChunkSize);
+
+                // Per-chunk retry state
+                let chunkRetryCount = 0;
+                let chunkTimeoutTimer = null;
+
+                // Running CRC32 state across all chunks for full-file checksum
+                // Initialized to 0xFFFFFFFF (pre-conditioning), finalized with XOR at the end
+                let runningCRC32State = 0xFFFFFFFF;
+                // Track which chunk indices have already been factored into the running state
+                // so that retries do not corrupt the full-file CRC32
+                let chunkCRC32Committed = {};
+
+                // Store file reference in retry map so the user can retry on failure
+                uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadDir))});
+
+                // Mark an upload task as failed and reveal the retry button
+                function markUploadFailed(tUUID) {
+                    clearTimeout(chunkTimeoutTimer);
+                    $(".uploadTask").each(function(){
+                        if ($(this).attr("taskID") == tUUID){
+                            $(this).find(".bar").css("width","100%");
+                            $(this).find(".progress:not(.percentage)").attr("class","ui tiny error progress");
+                            $(this).find(".uploadTaskRemoveIcon").show();
+                            $(this).find(".uploadTaskRetryBtn").show();
+                            $(this).addClass("ended");
+                            $(this).find(".progress.percentage").hide();
+                        }
+                    });
+                }
+
+                // Send a specific chunk by index.
+                // Reads the slice as ArrayBuffer, computes CRC32, sends metadata then binary.
+                // Sets a CHUNK_TIMEOUT_MS timer; on expiry retries up to MAX_CHUNK_RETRIES times.
+                async function sendChunk(id) {
+                    var offsetStart = id * uploadFileChunkSize;
+                    var offsetEnd   = id * uploadFileChunkSize + uploadFileChunkSize;
+                    var thisblob = file.slice(offsetStart, offsetEnd);
+
+                    let arrayBuffer;
+                    try {
+                        arrayBuffer = await thisblob.arrayBuffer();
+                    } catch(e) {
+                        console.error("[Upload] Failed to read chunk " + id + ": " + e);
+                        markUploadFailed(taskUUID);
+                        return;
                     }
                     }
-                    updateProgressForWebSocketUpload(taskUUID, progress)
-                    
+
+                    let bytes = new Uint8Array(arrayBuffer);
+
+                    // Update the running full-file CRC32 only on the first attempt for each
+                    // chunk index so that retries do not double-count the bytes
+                    if (!chunkCRC32Committed[id]) {
+                        runningCRC32State = crc32UpdateState(runningCRC32State, bytes);
+                        chunkCRC32Committed[id] = true;
+                    }
+
+                    // Compute a standalone CRC32 for this chunk for transmission verification
+                    let chunkCRCHex = crc32Hex(bytes);
+
+                    // Protocol: text metadata frame, then binary data frame
+                    socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
+                    socket.send(arrayBuffer);
+
+                    // Update progress (cap at 95% to leave room for the merge phase)
+                    var progress = chunks <= 1 ? 50 : (id / (chunks - 1) * 95.0);
+                    if (progress > 95) progress = 95;
+                    updateProgressForWebSocketUpload(taskUUID, progress);
+
+                    // (Re)start the per-chunk acknowledgement timeout
+                    clearTimeout(chunkTimeoutTimer);
+                    chunkTimeoutTimer = setTimeout(function() {
+                        if (chunkRetryCount < MAX_CHUNK_RETRIES) {
+                            chunkRetryCount++;
+                            console.warn("[Upload] Chunk " + id + " ACK timeout – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
+                            sendChunk(id);
+                        } else {
+                            console.error("[Upload] Chunk " + id + " failed after " + MAX_CHUNK_RETRIES + " retries");
+                            markUploadFailed(taskUUID);
+                            socket.close();
+                        }
+                    }, CHUNK_TIMEOUT_MS);
                 }
                 }
 
 
                 //Update all UI elements
                 //Update all UI elements
@@ -4737,37 +4825,42 @@
                     if ($(this).attr("taskID") == taskUUID){
                     if ($(this).attr("taskID") == taskUUID){
                         //This is the target upload task object. Hide its close button
                         //This is the target upload task object. Hide its close button
                         $(this).find(".uploadTaskRemoveIcon").hide();
                         $(this).find(".uploadTaskRemoveIcon").hide();
+                        $(this).find(".uploadTaskRetryBtn").hide();
                     }
                     }
                 });
                 });
 
 
                 //Start sending
                 //Start sending
-                socket.onopen = function(e) {
+                socket.onopen = async function(e) {
                     if (filesize < uploadFileChunkSize){
                     if (filesize < uploadFileChunkSize){
-                        //This file is smaller than chunk size, set it to somwhere within 10% - 20% so it doesn't look like it is stuck
-                        updateProgressForWebSocketUpload(taskUUID, 10 +  Math.floor(Math.random() * Math.floor(10)))
+                        //This file is smaller than chunk size, set it to somewhere within 10% - 20% so it doesn't look like it is stuck
+                        updateProgressForWebSocketUpload(taskUUID, 10 + Math.floor(Math.random() * 10));
                     }
                     }
-                    
                     //Send the first chunk
                     //Send the first chunk
-                    sendChunk(0);
-                    currentSendingIndex++;
+                    await sendChunk(0);
+                    currentSendingIndex = 1;
                 };
                 };
 
 
-                socket.onmessage = function(event) {
-                    //Append to the send index
+                socket.onmessage = async function(event) {
                     var incomingValue = event.data;
                     var incomingValue = event.data;
 
 
                     if (incomingValue == "next"){
                     if (incomingValue == "next"){
-                        if (currentSendingIndex == chunks + 1){
-                            //Already finished
-                            socket.send("done");
+                        // Server acknowledged the last chunk; clear timeout and reset retry counter
+                        clearTimeout(chunkTimeoutTimer);
+                        chunkRetryCount = 0;
+
+                        if (currentSendingIndex >= chunks){
+                            // All chunks sent and acknowledged – send done + full-file checksum
+                            let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+                            socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
                         }else{
                         }else{
                             //Send next chunk
                             //Send next chunk
-                            sendChunk(currentSendingIndex);
+                            await sendChunk(currentSendingIndex);
                             currentSendingIndex++;
                             currentSendingIndex++;
                         }
                         }
-                      
+
                     }else if (incomingValue == "OK"){
                     }else if (incomingValue == "OK"){
-                        //Merge completed
+                        //Merge completed successfully
+                        uploadRetryMap.delete(taskUUID);
                         $(".uploadTask").each(function(){
                         $(".uploadTask").each(function(){
                             if ($(this).attr("taskID") == taskUUID){
                             if ($(this).attr("taskID") == taskUUID){
                                 //Update this progress bar to completed
                                 //Update this progress bar to completed
@@ -4775,39 +4868,36 @@
                                 $(this).find(".progress:not(.percentage)").attr("class", "ui tiny success progress");
                                 $(this).find(".progress:not(.percentage)").attr("class", "ui tiny success progress");
                                 $(this).find(".progress.percentage").hide();
                                 $(this).find(".progress.percentage").hide();
                                 $(this).find(".uploadTaskRemoveIcon").show();
                                 $(this).find(".uploadTaskRemoveIcon").show();
+                                $(this).find(".uploadTaskRetryBtn").hide();
                                 $(this).addClass("ended");
                                 $(this).addClass("ended");
-                               
-                                
                                 $.when($(this).delay(1000).fadeOut("fast")).then(function(){
                                 $.when($(this).delay(1000).fadeOut("fast")).then(function(){
                                     $(this).remove();
                                     $(this).remove();
                                     updateUploadFileCount();
                                     updateUploadFileCount();
                                 });
                                 });
-                                
                             }
                             }
                         });
                         });
                     }else{
                     }else{
                         //Try to parse it as JSON
                         //Try to parse it as JSON
                         try{
                         try{
-                            var resp = JSON.parse(incomingValue.split('\\' + '"').join("\""));
-                            //console.log(resp);
+                            var resp = JSON.parse(incomingValue);
                             if (resp.error !== undefined){
                             if (resp.error !== undefined){
-                                //This is an error message
-                                msgbox("red remove",resp.error);
-
-                                //Update the progress bar to error
-                                $(".uploadTask").each(function(){
-                                    if ($(this).attr("taskID") == taskUUID){
-                                        //Update this progress bar to completed
-                                        $(this).find(".bar").css("width","100%");
-                                        $(this).find(".progress:not(.percentage)").attr("class","ui tiny error progress");
-                                        $(this).find(".uploadTaskRemoveIcon").show();
-                                        $(this).addClass("ended");
-                                        $(this).find(".progress.percentage").hide();
-                                    }
-                                });
+                                //Server reported an error
+                                msgbox("red remove", resp.error);
+                                markUploadFailed(taskUUID);
+                            }else if (resp.retryChunk !== undefined){
+                                // Server detected a CRC32 mismatch and requests a chunk re-send
+                                clearTimeout(chunkTimeoutTimer);
+                                if (chunkRetryCount < MAX_CHUNK_RETRIES){
+                                    chunkRetryCount++;
+                                    console.warn("[Upload] Server requested retry for chunk " + resp.retryChunk + " (CRC32 mismatch) – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
+                                    await sendChunk(resp.retryChunk);
+                                } else {
+                                    console.error("[Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
+                                    markUploadFailed(taskUUID);
+                                    socket.close();
+                                }
                             }else if (resp.move !== undefined){
                             }else if (resp.move !== undefined){
-                                //File move from tmp to archive progress
-                                //Update the progress bar to show move progress
+                                //File move from tmp to archive – show progress
                                 $(".uploadTask").each(function(){
                                 $(".uploadTask").each(function(){
                                     if ($(this).attr("taskID") == taskUUID){
                                     if ($(this).attr("taskID") == taskUUID){
                                         $(this).find(".bar").css("width","100%");
                                         $(this).find(".bar").css("width","100%");
@@ -4826,9 +4916,10 @@
                 };
                 };
 
 
                 socket.onclose = function(event) {
                 socket.onclose = function(event) {
+                    clearTimeout(chunkTimeoutTimer);
                     uploadingFileCount--;
                     uploadingFileCount--;
                     updateUploadFileCount();
                     updateUploadFileCount();
-                     //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
+                    //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
                     setTimeout(function(){
                     setTimeout(function(){
                         if (uploadPendingList.length > 0){
                         if (uploadPendingList.length > 0){
                             let nextFile = uploadPendingList.shift();
                             let nextFile = uploadPendingList.shift();
@@ -4838,19 +4929,9 @@
                 };
                 };
 
 
                 socket.onerror = function(error) {
                 socket.onerror = function(error) {
-                    console.log(error.message);
-                    console.log("Unable to open WebSocket connection. Fall back to FORM POST upload mode. (Check your nginx / reverse proxy settings!!!)")
-                    //Try to fallback to FORM POST upload mode
-                    lowMemoryMode = false;
-                   
-                    uploadPendingList.push({
-                        File: file,
-                        UUID: taskUUID,
-                        TargetDir: JSON.parse(JSON.stringify(targetDir)),
-                    });
-                    
-                   
-                    return
+                    console.error("[Upload] WebSocket error:", error);
+                    // Mark the task as failed and show the retry button
+                    markUploadFailed(taskUUID);
                 };
                 };
 
 
             }else{
             }else{
@@ -5221,6 +5302,9 @@
                         <div class="progress percentage"></div>
                         <div class="progress percentage"></div>
                     </div>
                     </div>
                 </div>
                 </div>
+                <div class="uploadTaskRetryBtn" onclick="retryUploadFile('${newuuid}');" style="display:none; cursor:pointer; float:left; margin-right:4px;" title="Retry upload">
+                    <i class="redo icon"></i>
+                </div>
                 <div class="uploadTaskRemoveIcon" onclick="removeThisTask(this, '${newuuid}');" style="">
                 <div class="uploadTaskRemoveIcon" onclick="removeThisTask(this, '${newuuid}');" style="">
                     <i class="remove icon"></i>
                     <i class="remove icon"></i>
                 </div>
                 </div>
@@ -5228,6 +5312,27 @@
             return newuuid;
             return newuuid;
         }
         }
 
 
+        function retryUploadFile(taskUUID){
+            let retryInfo = uploadRetryMap.get(taskUUID);
+            if (!retryInfo){
+                console.warn("[Upload] No retry info found for task " + taskUUID);
+                return;
+            }
+            // Reset the task UI back to pending state
+            $(".uploadTask").each(function(){
+                if ($(this).attr("taskID") == taskUUID){
+                    $(this).removeClass("ended");
+                    $(this).find(".bar").css("width","0%");
+                    $(this).find(".progress:not(.percentage)").attr("class","ui small themed progress");
+                    $(this).find(".progress.percentage").text("").show();
+                    $(this).find(".uploadTaskRetryBtn").hide();
+                    $(this).find(".uploadTaskRemoveIcon").hide();
+                }
+            });
+            // Re-queue the file for upload using the same task UUID
+            uploadFile(retryInfo.file, taskUUID, retryInfo.targetDir);
+        }
+
         function removeThisTask(object, taskUUID){
         function removeThisTask(object, taskUUID){
             //Remove item from uploadPendingList
             //Remove item from uploadPendingList
             let removeId = -1;
             let removeId = -1;

+ 183 - 54
src/web/desktop.html

@@ -1500,6 +1500,27 @@
         let uploadFileChunkSize = 1024 * 512; //512KB per chunk in low memory upload
         let uploadFileChunkSize = 1024 * 512; //512KB per chunk in low memory upload
         let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
         let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
         let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
         let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
+        const CHUNK_TIMEOUT_MS = 30000; //30s to wait for server ack before retrying a chunk
+        const MAX_CHUNK_RETRIES = 3;    //Maximum per-chunk retries before marking the upload failed
+
+        //CRC32 lookup table and helpers (IEEE polynomial, matches Go's hash/crc32 package)
+        const crc32Table = (() => {
+            const t = new Uint32Array(256);
+            for (let i = 0; i < 256; i++) {
+                let c = i;
+                for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
+                t[i] = c;
+            }
+            return t;
+        })();
+        function crc32UpdateState(state, bytes) {
+            for (let i = 0; i < bytes.length; i++)
+                state = (state >>> 8) ^ crc32Table[(state ^ bytes[i]) & 0xFF];
+            return state;
+        }
+        function crc32Hex(bytes) {
+            return ((crc32UpdateState(0xFFFFFFFF, bytes) ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+        }
 
 
         //Others
         //Others
         var monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
         var monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
@@ -2184,8 +2205,16 @@
                 let hoverOffset = [event.pageX - $(this).offset().left, event.pageY - $(this).offset().top];
                 let hoverOffset = [event.pageX - $(this).offset().left, event.pageY - $(this).offset().top];
                 if (hoverOffset[1] <= 3 && !$(this).parent().hasClass("fixedsize")){
                 if (hoverOffset[1] <= 3 && !$(this).parent().hasClass("fixedsize")){
                     $(this).addClass("resizbleCursor");
                     $(this).addClass("resizbleCursor");
+                    if (hoverOffset[0] < 15) {
+                        $(this).css("cursor", "nw-resize");
+                    } else if (hoverOffset[0] > $(this).parent().width() - 15) {
+                        $(this).css("cursor", "ne-resize");
+                    } else {
+                        $(this).css("cursor", "");
+                    }
                 }else{
                 }else{
                     $(this).removeClass("resizbleCursor");
                     $(this).removeClass("resizbleCursor");
+                    $(this).css("cursor", "");
                 }
                 }
             });
             });
             $(".fwdragger").off("mouseup").on("mouseup", function(evt) {
             $(".fwdragger").off("mouseup").on("mouseup", function(evt) {
@@ -2238,6 +2267,11 @@
             });
             });
 
 
             $(".closetoggle").off("touchstart").on("touchstart",function(evt){
             $(".closetoggle").off("touchstart").on("touchstart",function(evt){
+                // If touch is within the 3px top-edge zone, let fwdragger handle corner resize
+                var fwTop = $(this).closest(".floatWindow").offset().top;
+                if (evt.pageY - fwTop <= 3 && !$(this).closest(".floatWindow").hasClass("fixedsize")) {
+                    return;
+                }
                 evt.preventDefault();
                 evt.preventDefault();
                 evt.stopImmediatePropagation();
                 evt.stopImmediatePropagation();
                 closeFloatWindow(this,evt);
                 closeFloatWindow(this,evt);
@@ -2269,11 +2303,30 @@
             });
             });
 
 
             $(".closetoggle").off("mousedown").on("mousedown",function(evt){
             $(".closetoggle").off("mousedown").on("mousedown",function(evt){
+                // If the click lands in the 10px top-edge zone, let the event bubble to
+                // fwdragger so it can detect and start a top-corner resize instead
+                var fwTop = $(this).closest(".floatWindow").offset().top;
+                if (resizingWindow && evt.pageY - fwTop <= 10 && !$(this).closest(".floatWindow").hasClass("fixedsize")) {
+                    return;
+                }
                 evt.preventDefault();
                 evt.preventDefault();
                 evt.stopImmediatePropagation();
                 evt.stopImmediatePropagation();
                 closeFloatWindow(this,evt);
                 closeFloatWindow(this,evt);
             });
             });
 
 
+            
+            $(".closetoggle").off("mouseup").on("mouseup",function(evt){
+                // If the mouse up lands in the 10px top-edge zone, let the event bubble to
+                // fwdragger so it can detect and start a top-corner resize instead
+                var fwTop = $(this).closest(".floatWindow").offset().top;
+                if (resizingWindow && evt.pageY - fwTop <= 10 && !$(this).closest(".floatWindow").hasClass("fixedsize")) {
+                    // Stop resizing and restore cursor to default if mouse up on close button while resizing from top edge
+                    mainWindowMouseup(evt);
+                    return;
+                }
+            });
+            
+
             $(".dockleft").off("mousedown").on("mousedown",function(evt){
             $(".dockleft").off("mousedown").on("mousedown",function(evt){
                 evt.preventDefault();
                 evt.preventDefault();
                 evt.stopImmediatePropagation();
                 evt.stopImmediatePropagation();
@@ -2612,6 +2665,30 @@
                         "top": posY - 2,
                         "top": posY - 2,
                         "height": Math.max(newHeight,minHeight),
                         "height": Math.max(newHeight,minHeight),
                     });
                     });
+                } else if (resizingEdgeID == 7){
+                    // Top-left corner: resize height from top + width from left
+                    var newHeight = $(object).height() - (posY - $(object).offset().top) + 2;
+                    var newWidth = $(object).offset().left - posX + $(object).width();
+                    var newLeft = posX;
+                    if (newWidth < minxWidth) {
+                        newLeft = $(object).offset().left + $(object).width() - minxWidth;
+                        newWidth = minxWidth;
+                    }
+                    $(object).css({
+                        "top": posY - 2,
+                        "height": Math.max(newHeight, minHeight),
+                        "left": newLeft,
+                        "width": newWidth
+                    });
+                } else if (resizingEdgeID == 8){
+                    // Top-right corner: resize height from top + width from right
+                    var newHeight = $(object).height() - (posY - $(object).offset().top) + 2;
+                    var newWidth = posX - $(object).offset().left + 2;
+                    $(object).css({
+                        "top": posY - 2,
+                        "height": Math.max(newHeight, minHeight),
+                        "width": Math.max(newWidth, minxWidth)
+                    });
                 }
                 }
             }
             }
         }
         }
@@ -2717,10 +2794,18 @@
             //Get the relative click offset of the down event
             //Get the relative click offset of the down event
             clickDownOffset = [event.pageX - $(object).offset().left, event.pageY - $(object).offset().top];
             clickDownOffset = [event.pageX - $(object).offset().left, event.pageY - $(object).offset().top];
             if (clickDownOffset[1] <= 3 && !$(object).hasClass("fixedsize")){
             if (clickDownOffset[1] <= 3 && !$(object).hasClass("fixedsize")){
-                //Resizing Top Edge
+                //Resizing Top Edge (with corner detection)
                 resizingWindow = true;
                 resizingWindow = true;
                 resizingWindowTarget = $(object);
                 resizingWindowTarget = $(object);
-                resizingEdgeID = 6;
+                if (clickDownOffset[0] < 10) {
+                    resizingEdgeID = 7; // top-left corner
+                    $("#fwdragpanel, #tfwdragpanel").css("cursor", "nw-resize");
+                } else if (clickDownOffset[0] > $(object).width() - 10) {
+                    resizingEdgeID = 8; // top-right corner
+                    $("#fwdragpanel, #tfwdragpanel").css("cursor", "ne-resize");
+                } else {
+                    resizingEdgeID = 6; // top edge
+                }
             }else{
             }else{
                 //Moving Window
                 //Moving Window
                 movingWindow = true;
                 movingWindow = true;
@@ -2774,7 +2859,7 @@
 
 
                 $(object).css("left", event.pageX - clickDownOffset[0]);
                 $(object).css("left", event.pageX - clickDownOffset[0]);
                 $(object).css("top", event.pageY - clickDownOffset[1]);
                 $(object).css("top", event.pageY - clickDownOffset[1]);
-            } else if (resizingWindow && resizingEdgeID == 6){
+            } else if (resizingWindow && (resizingEdgeID == 6 || resizingEdgeID == 7 || resizingEdgeID == 8)){
                 resizeMove($(object), event);
                 resizeMove($(object), event);
             }
             }
         }
         }
@@ -3730,7 +3815,6 @@
 
 
         $("body").on("mouseup", function(evt) {
         $("body").on("mouseup", function(evt) {
             mainWindowMouseup(evt);
             mainWindowMouseup(evt);
-
         });
         });
         $("body").on("touchend", function(evt) {
         $("body").on("touchend", function(evt) {
             mainWindowMouseup(evt);
             mainWindowMouseup(evt);
@@ -3756,6 +3840,7 @@
                 resizingEdgeID = 0;
                 resizingEdgeID = 0;
                 $("#fwdragpanel").hide();
                 $("#fwdragpanel").hide();
                 $("#tfwdragpanel").hide();
                 $("#tfwdragpanel").hide();
+                $("#fwdragpanel, #tfwdragpanel").css("cursor", "");
                 $('.floatWindow.topmost').css("pointer-events", "auto");
                 $('.floatWindow.topmost').css("pointer-events", "auto");
                 $('.floatWindow.topmost').css("opacity", 1);
                 $('.floatWindow.topmost').css("opacity", 1);
                 $(resizingWindowTarget).find(".iframecover").hide();
                 $(resizingWindowTarget).find(".iframecover").hide();
@@ -6882,88 +6967,131 @@
 
 
                 let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + filename + "&path=" + uploadDir + hugeFileMode);
                 let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + filename + "&path=" + uploadDir + hugeFileMode);
                 let currentSendingIndex = 0;
                 let currentSendingIndex = 0;
-                let chunks = Math.ceil(file.size/uploadFileChunkSize,uploadFileChunkSize);
-                
-                //Define a function for sending a particular chunk
-                function sendChunk(id, uploadingIconUUID){
-                    let offsetStart = id*uploadFileChunkSize;
-                    let offsetEnd = id*uploadFileChunkSize + uploadFileChunkSize;
-                    let thisblob = file.slice(offsetStart,offsetEnd);
-                    socket.send(thisblob);
-                    //console.log(id + "/" + chunks);
-
-                    //Update progress to first percentage
-                    let progress = id / (chunks-1) * 100.0;
-                    if (progress > 100){
-                        progress = 100;
+                let chunks = Math.ceil(file.size/uploadFileChunkSize);
+
+                // Per-chunk retry state
+                let chunkRetryCount = 0;
+                let chunkTimeoutTimer = null;
+
+                // Running CRC32 state across all chunks for full-file checksum
+                let runningCRC32State = 0xFFFFFFFF;
+                let chunkCRC32Committed = {};
+
+                async function sendChunk(id) {
+                    let offsetStart = id * uploadFileChunkSize;
+                    let offsetEnd   = id * uploadFileChunkSize + uploadFileChunkSize;
+                    let arrayBuffer;
+                    try {
+                        arrayBuffer = await file.slice(offsetStart, offsetEnd).arrayBuffer();
+                    } catch(e) {
+                        console.error("[Desktop Upload] Failed to read chunk " + id + ": " + e);
+                        return;
                     }
                     }
 
 
+                    let bytes = new Uint8Array(arrayBuffer);
 
 
+                    // Update running full-file CRC32 only on first attempt for each chunk
+                    if (!chunkCRC32Committed[id]) {
+                        runningCRC32State = crc32UpdateState(runningCRC32State, bytes);
+                        chunkCRC32Committed[id] = true;
+                    }
+
+                    let chunkCRCHex = crc32Hex(bytes);
+
+                    // Send metadata frame then binary frame
+                    socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
+                    socket.send(arrayBuffer);
+
+                    // Update desktop icon progress (cap at 95% to reserve room for merge)
+                    let progress = chunks <= 1 ? 50 : (id / (chunks - 1) * 95.0);
+                    if (progress > 95) progress = 95;
                     if (uploadingIconUUID != undefined){
                     if (uploadingIconUUID != undefined){
-                        //Update the progress on the object
                         $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", progress + "%");
                         $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", progress + "%");
-                        if (progress == 100){
+                        if (progress >= 95){
                             $("." + uploadingIconUUID + ".launchIcon").find(".progress").addClass("indeterminate");
                             $("." + uploadingIconUUID + ".launchIcon").find(".progress").addClass("indeterminate");
                         }
                         }
                     }
                     }
-                 
+
+                    // Start ack timeout; retry chunk on expiry
+                    clearTimeout(chunkTimeoutTimer);
+                    chunkTimeoutTimer = setTimeout(function() {
+                        if (chunkRetryCount < MAX_CHUNK_RETRIES) {
+                            chunkRetryCount++;
+                            console.warn("[Desktop Upload] Chunk " + id + " ACK timeout – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
+                            sendChunk(id);
+                        } else {
+                            console.error("[Desktop Upload] Chunk " + id + " failed after max retries");
+                            socket.close();
+                        }
+                    }, CHUNK_TIMEOUT_MS);
                 }
                 }
 
 
                 //Start sending
                 //Start sending
-                socket.onopen = function(e) {
+                socket.onopen = async function(e) {
                     if (filesize < uploadFileChunkSize){
                     if (filesize < uploadFileChunkSize){
-                        //This file is smaller than chunk size, set it to somwhere within 10% - 20% so it doesn't look like it is stuck
-                        $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", "15%");
+                        if (uploadingIconUUID != undefined){
+                            $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", "15%");
+                        }
                     }
                     }
-                    
-                    //Send the first chunk
-                    sendChunk(0, uploadingIconUUID);
-                    currentSendingIndex++;
+                    await sendChunk(0);
+                    currentSendingIndex = 1;
                 };
                 };
 
 
-                socket.onmessage = function(event) {
-                    //Append to the send index
+                socket.onmessage = async function(event) {
                     var incomingValue = event.data;
                     var incomingValue = event.data;
 
 
                     if (incomingValue == "next"){
                     if (incomingValue == "next"){
-                        if (currentSendingIndex == chunks + 1){
-                            //Already finished
-                            socket.send("done");
+                        clearTimeout(chunkTimeoutTimer);
+                        chunkRetryCount = 0;
+
+                        if (currentSendingIndex >= chunks){
+                            // All chunks acknowledged – send done with full-file checksum
+                            let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
+                            socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
                         }else{
                         }else{
-                            //Send next chunk
-                            sendChunk(currentSendingIndex, uploadingIconUUID);
+                            await sendChunk(currentSendingIndex);
                             currentSendingIndex++;
                             currentSendingIndex++;
                         }
                         }
-                      
+
                     }else if (incomingValue == "OK"){
                     }else if (incomingValue == "OK"){
-                        //Merge completed
-                        
+                        //Merge completed successfully – icon will be cleaned up by onclose
                     }else{
                     }else{
-                        //Try to parse it as JSON
                         try{
                         try{
-                            var resp = JSON.parse(incomingValue.split('\\' + '"').join("\""));
-                            console.log(resp);
+                            var resp = JSON.parse(incomingValue);
                             if (resp.error !== undefined){
                             if (resp.error !== undefined){
-                                //This is an error message
-                                sendNotification("Upload Failed", resp.error, "remove")
-
-                                //Update the progress bar to error
+                                sendNotification("Upload Failed", resp.error, "remove");
+                                clearTimeout(chunkTimeoutTimer);
                                 $("." + uploadingIconUUID + ".launchIcon").find(".progress").removeClass("primary").addClass("error");
                                 $("." + uploadingIconUUID + ".launchIcon").find(".progress").removeClass("primary").addClass("error");
                                 $("." + uploadingIconUUID + ".launchIcon").find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
                                 $("." + uploadingIconUUID + ".launchIcon").find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
                                 setTimeout(function(){
                                 setTimeout(function(){
-                                    $("." + uploadingIconUUID + ".launchIcon").fadeOut(3000,function() { $(this).remove(); });
+                                    $("." + uploadingIconUUID + ".launchIcon").fadeOut(3000, function() { $(this).remove(); });
                                 }, 2000);
                                 }, 2000);
+                            } else if (resp.retryChunk !== undefined){
+                                // Server-requested chunk retry (CRC32 mismatch)
+                                clearTimeout(chunkTimeoutTimer);
+                                if (chunkRetryCount < MAX_CHUNK_RETRIES) {
+                                    chunkRetryCount++;
+                                    console.warn("[Desktop Upload] Server requested retry for chunk " + resp.retryChunk);
+                                    await sendChunk(resp.retryChunk);
+                                } else {
+                                    console.error("[Desktop Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
+                                    socket.close();
+                                }
+                            } else if (resp.move !== undefined){
+                                // Server is moving file from tmp to destination
+                                if (uploadingIconUUID != undefined){
+                                    $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", "100%");
+                                }
                             }
                             }
                         }catch(ex){
                         }catch(ex){
-                            //Something else
                             console.log(incomingValue);
                             console.log(incomingValue);
                             console.log(ex);
                             console.log(ex);
                         }
                         }
                     }
                     }
-
                 };
                 };
 
 
                 socket.onclose = function(event) {
                 socket.onclose = function(event) {
+                    clearTimeout(chunkTimeoutTimer);
                     $("." + uploadingIconUUID).remove();
                     $("." + uploadingIconUUID).remove();
                     if (callback != undefined){
                     if (callback != undefined){
                         callback();
                         callback();
@@ -6971,12 +7099,13 @@
                 };
                 };
 
 
                 socket.onerror = function(error) {
                 socket.onerror = function(error) {
-                    console.log(error.message);
-                    console.log("[Desktop] Unable to open WebSocket connection. Fall back to FORM POST upload mode. (Check your nginx / reverse proxy settings!!!)")
-                    //Try to fallback to FORM POST upload mode
-                    lowMemoryMode = false;
-                    uploadFile(thisFile, callback)
-                    return
+                    console.error("[Desktop Upload] WebSocket error:", error);
+                    clearTimeout(chunkTimeoutTimer);
+                    $("." + uploadingIconUUID + ".launchIcon").find(".progress").removeClass("primary").addClass("error");
+                    $("." + uploadingIconUUID + ".launchIcon").find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
+                    setTimeout(function(){
+                        $("." + uploadingIconUUID + ".launchIcon").fadeOut(3000, function() { $(this).remove(); });
+                    }, 2000);
                 };
                 };
             }else{
             }else{
                 let url = 'system/file_system/upload'
                 let url = 'system/file_system/upload'