diff --git a/client/app.go b/client/app.go index be526a4..8f6b3c2 100644 --- a/client/app.go +++ b/client/app.go @@ -125,20 +125,25 @@ type App struct { sessionPath string statsPath string webviewDataPath string + pendingViolPath string + runLockPath string wsConn *websocket.Conn - wsConnected bool - wifiSSID string - wifiBSSID string - allowedApps string - onlineSecs int - offlineSecs int - unsyncedOn int - unsyncedOff int - lastSyncTime time.Time - isStreamingSc bool - streamScStop chan struct{} - isStreamingCam bool - streamCamStop chan struct{} + wsConnected bool + wsConnecting bool + wsBackoff time.Duration + wsWriteMu sync.Mutex + wifiSSID string + wifiBSSID string + allowedApps string + onlineSecs int + offlineSecs int + unsyncedOn int + unsyncedOff int + lastSyncTime time.Time + isStreamingSc bool + streamScStop chan struct{} + isStreamingCam bool + streamCamStop chan struct{} statusMsg string expectingLogin bool needsClearPortalStorage bool @@ -152,7 +157,6 @@ type App struct { chatUnread int replyStaffID uint fetchAppsMu sync.Mutex - wsWriteMu sync.Mutex } type LocalStats struct { @@ -213,11 +217,13 @@ func NewApp() *App { _ = os.MkdirAll(webviewDir, 0755) return &App{ - sessionPath: filepath.Join(appDir, "student_session.json"), - statsPath: filepath.Join(appDir, "student_stats.json"), - webviewDataPath: webviewDir, - lastSyncTime: time.Now(), - expectingLogin: false, + sessionPath: filepath.Join(appDir, "student_session.json"), + statsPath: filepath.Join(appDir, "student_stats.json"), + webviewDataPath: webviewDir, + pendingViolPath: filepath.Join(appDir, "pending_violations.json"), + runLockPath: filepath.Join(appDir, "monitoring.lock"), + lastSyncTime: time.Now(), + expectingLogin: false, } } @@ -226,6 +232,10 @@ func (a *App) startup(ctx context.Context) { a.loadSession() a.loadStats() + // Task Manager / kill process lần trước → còn file lock → báo unclean_shutdown + a.detectUncleanShutdown() + go a.flushPendingViolations() + // Request Location Access (macOS) winapi.RequestLocationAccess() winapi.RequestCameraAndMicAccess() @@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) { go a.authStorageScanner() } -func (a *App) handleGuardViolation(reason string) { +func (a *App) handleGuardViolation(kind, reason string) { + if kind == "" { + kind = "guard" + } + a.reportViolation(kind, reason) a.showQuitDialog("Vi phạm giám sát", reason) } +// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm. +// Trả về false để cho phép đóng sau khi đã báo cáo. +func (a *App) HandleBeforeClose() (prevent bool) { + if !a.CheckLoginStatus() || !a.isMonitoringActive() { + a.clearRunLock() + return false + } + a.mu.Lock() + mode := a.dashboard.MonitorMode + a.mu.Unlock() + if mode == "" || mode == "not_configured" { + a.clearRunLock() + return false + } + + a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")") + a.tearDownBeforeQuit() + a.clearRunLock() + return false +} + // tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối). func (a *App) tearDownBeforeQuit() { a.mu.Lock() @@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() { a.stopWebcamStream() a.disconnectWS() guard.Stop() + a.clearRunLock() } func (a *App) isMonitoringActive() bool { @@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) { runtime.Quit(a.ctx) } +type pendingViolation struct { + Kind string `json:"kind"` + Reason string `json:"reason"` + MonitorMode string `json:"monitorMode"` + ClientAt string `json:"clientAt"` + StudentRkID int64 `json:"studentRkId"` +} + +func (a *App) markRunLock() { + a.mu.Lock() + studentID := int64(0) + mode := a.dashboard.MonitorMode + if a.student != nil { + studentID = a.student.StudentID + } + a.mu.Unlock() + if studentID <= 0 || mode == "" || mode == "not_configured" { + return + } + payload, _ := json.Marshal(map[string]any{ + "studentRkId": studentID, + "monitorMode": mode, + "startedAt": time.Now().Format(time.RFC3339), + }) + _ = os.WriteFile(a.runLockPath, payload, 0644) +} + +func (a *App) clearRunLock() { + _ = os.Remove(a.runLockPath) +} + +func (a *App) detectUncleanShutdown() { + data, err := os.ReadFile(a.runLockPath) + if err != nil { + return + } + _ = os.Remove(a.runLockPath) + + var meta struct { + StudentRkID int64 `json:"studentRkId"` + MonitorMode string `json:"monitorMode"` + StartedAt string `json:"startedAt"` + } + _ = json.Unmarshal(data, &meta) + if meta.StudentRkID <= 0 { + a.mu.Lock() + if a.student != nil { + meta.StudentRkID = a.student.StudentID + } + a.mu.Unlock() + } + if meta.StudentRkID <= 0 { + return + } + reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát" + if meta.MonitorMode != "" { + reason += " (" + meta.MonitorMode + ")" + } + a.enqueuePendingViolation(pendingViolation{ + Kind: "unclean_shutdown", + Reason: reason, + MonitorMode: meta.MonitorMode, + ClientAt: time.Now().Format(time.RFC3339), + StudentRkID: meta.StudentRkID, + }) +} + +func (a *App) enqueuePendingViolation(v pendingViolation) { + list := a.loadPendingViolations() + list = append(list, v) + raw, _ := json.Marshal(list) + _ = os.WriteFile(a.pendingViolPath, raw, 0644) +} + +func (a *App) loadPendingViolations() []pendingViolation { + data, err := os.ReadFile(a.pendingViolPath) + if err != nil { + return nil + } + var list []pendingViolation + if json.Unmarshal(data, &list) != nil { + return nil + } + return list +} + +func (a *App) flushPendingViolations() { + list := a.loadPendingViolations() + if len(list) == 0 { + return + } + remaining := make([]pendingViolation, 0) + for _, v := range list { + if !a.postViolation(v) { + remaining = append(remaining, v) + } + } + if len(remaining) == 0 { + _ = os.Remove(a.pendingViolPath) + return + } + raw, _ := json.Marshal(remaining) + _ = os.WriteFile(a.pendingViolPath, raw, 0644) +} + +func (a *App) postViolation(v pendingViolation) bool { + if v.StudentRkID <= 0 { + return true + } + payload := map[string]any{ + "studentRkId": v.StudentRkID, + "kind": v.Kind, + "reason": v.Reason, + "monitorMode": v.MonitorMode, + "clientAt": v.ClientAt, + } + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + log.Printf("[VIOLATION] report failed: %v", err) + return false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body)) + return false + } + log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID) + return true +} + +func (a *App) reportViolation(kind, reason string) { + a.mu.Lock() + studentID := int64(0) + mode := a.dashboard.MonitorMode + if a.student != nil { + studentID = a.student.StudentID + } + a.mu.Unlock() + if studentID <= 0 { + return + } + v := pendingViolation{ + Kind: kind, + Reason: reason, + MonitorMode: mode, + ClientAt: time.Now().Format(time.RFC3339), + StudentRkID: studentID, + } + if !a.postViolation(v) { + a.enqueuePendingViolation(v) + } +} + // startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview func (a *App) startLocalServer() { mux := http.NewServeMux() @@ -392,6 +584,16 @@ func (a *App) startLocalServer() { }() w.Write([]byte("ok")) }) + mux.HandleFunc("/exam-clear-cache", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + return + } + // Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe. + go a.purgeWebViewDiskCache() + w.Write([]byte("ok")) + }) go func() { _ = server.ListenAndServe() @@ -408,26 +610,95 @@ const examViewHTML = `