Files
rikkei_simple_care/client/app.go
Phuoc NTB a3b986674e
All checks were successful
Deploy on Master Change / deploy (push) Successful in 42s
fix: resolve concurrent write crash on gorilla/websocket by adding wsWriteMu mutex
2026-07-01 13:42:13 +07:00

1887 lines
47 KiB
Go

package main
import (
"archive/zip"
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"encoding/base64"
"errors"
"fmt"
"html"
"io"
"log"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"client/internal/blocker"
"client/internal/camera"
"client/internal/guard"
"client/internal/screen"
"client/internal/winapi"
"github.com/gorilla/websocket"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var blockedReportLast sync.Map // processName -> time.Time
var lastKillDialogTime time.Time
var killDialogMu sync.Mutex
var API_BASE = getEnv("API_BASE", "https://sv.rikkeiraia.org")
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
func getWsUrl(apiBase string) string {
if strings.HasPrefix(apiBase, "https://") {
return strings.Replace(apiBase, "https://", "wss://", 1)
}
if strings.HasPrefix(apiBase, "http://") {
return strings.Replace(apiBase, "http://", "ws://", 1)
}
return "ws://" + apiBase
}
var wifiReportLast sync.Map // ssidKey -> time.Time
var encryptionKey = []byte("simple-care-proctor-secretkey32!") // 32-byte key for AES-256
func encrypt(data []byte) ([]byte, error) {
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, data, nil)
return ciphertext, nil
}
func decrypt(data []byte) ([]byte, error) {
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
type StudentData struct {
StudentID int64 `json:"studentId"`
FullName string `json:"fullName"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Dob string `json:"dob"`
StudentCode string `json:"studentCode"`
Phone string `json:"phone"`
SystemID int64 `json:"systemId"`
}
type App struct {
ctx context.Context
student *StudentData
mu sync.Mutex
sessionPath string
statsPath string
webviewDataPath 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{}
statusMsg string
expectingLogin bool
needsClearPortalStorage bool
backendOnline bool
dashboard StudentDashboardSnapshot
wifiEnforce bool
acceptedWifi map[string]bool
wifiRejected bool
quitDialogShown bool
monitoringTornDown bool
chatUnread int
replyStaffID uint
fetchAppsMu sync.Mutex
wsWriteMu sync.Mutex
}
type LocalStats struct {
OnlineSecs int `json:"onlineSecs"`
OfflineSecs int `json:"offlineSecs"`
UnsyncedOn int `json:"unsyncedOn"`
UnsyncedOff int `json:"unsyncedOff"`
}
type StudentShiftSnapshot struct {
Period int `json:"period"`
CourseName string `json:"courseName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsActiveNow bool `json:"isActiveNow"`
OnlineSeconds int `json:"onlineSeconds"`
OfflineSeconds int `json:"offlineSeconds"`
AttendanceStatus int `json:"attendanceStatus"`
AttendanceLabel string `json:"attendanceLabel"`
}
type StudentExamSnapshot struct {
ExamRoomID uint `json:"examRoomId"`
ExamName string `json:"examName"`
QuizURL string `json:"quizUrl"`
PaperSent bool `json:"paperSent"`
PaperTitle string `json:"paperTitle"`
Submitted bool `json:"submitted"`
}
type StudentDashboardSnapshot struct {
SessionDate string `json:"sessionDate"`
MonitorMode string `json:"monitorMode"`
MonitorLabel string `json:"monitorLabel"`
ClassRkID int64 `json:"classRkId"`
ClassName string `json:"className"`
ClassCode string `json:"classCode"`
CurrentPeriod int `json:"currentPeriod"`
CurrentCourseName string `json:"currentCourseName"`
CurrentShiftStart string `json:"currentShiftStart"`
CurrentShiftEnd string `json:"currentShiftEnd"`
InScheduleNow bool `json:"inScheduleNow"`
BlockerActive bool `json:"blockerActive"`
Shifts []StudentShiftSnapshot `json:"shifts"`
Exam *StudentExamSnapshot `json:"exam,omitempty"`
}
func NewApp() *App {
configDir, err := os.UserConfigDir()
if err != nil {
// Fallback to executable folder if UserConfigDir is unavailable
exePath, _ := os.Executable()
configDir = filepath.Dir(exePath)
}
appDir := filepath.Join(configDir, "SimpleCare")
webviewDir := filepath.Join(appDir, "WebView2")
_ = os.MkdirAll(appDir, 0755)
_ = 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,
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.loadSession()
a.loadStats()
// Request Location Access (macOS)
winapi.RequestLocationAccess()
winapi.RequestCameraAndMicAccess()
winapi.RequestScreenCaptureAccess()
// Log initial permission statuses
log.Printf("[PERMISSIONS] Camera Status: %d (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", winapi.GetCameraPermission())
log.Printf("[PERMISSIONS] Microphone Status: %d (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", winapi.GetMicrophonePermission())
log.Printf("[PERMISSIONS] Screen Capture Status: %d (0=NoAccess, 1=Authorized, -1=NotSupported)", winapi.GetScreenCapturePermission())
// Register blocker callbacks
blocker.Instance.OnBlocked = func(procName string, title string) {
go a.reportBlockedApp(procName, title)
}
blocker.Instance.OnKill = func(procName string, title string) {
killDialogMu.Lock()
if time.Since(lastKillDialogTime) < 5*time.Second {
killDialogMu.Unlock()
return
}
lastKillDialogTime = time.Now()
killDialogMu.Unlock()
winapi.ShowWarningMessageBox(
"Ứng dụng bị đóng",
fmt.Sprintf("Hệ thống đã tự động đóng ứng dụng '%s' (%s) do không nằm trong danh sách các ứng dụng được phép sử dụng trong giờ học.", procName, title),
)
}
// Giám sát môi trường Windows: đa màn hình, desktop ảo, đổi user
guard.Start(a.handleGuardViolation)
// Khởi chạy HTTP server nhận callback login thành công
a.startLocalServer()
// Khởi chạy vòng lặp giám sát định kỳ (mỗi 10 giây)
go a.monitorLoop()
// Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập
go a.authStorageScanner()
}
func (a *App) handleGuardViolation(reason string) {
a.showQuitDialog("Vi phạm giám sát", reason)
}
// 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()
if a.monitoringTornDown {
a.mu.Unlock()
return
}
a.monitoringTornDown = true
a.mu.Unlock()
blocker.Instance.Stop()
a.stopScreenshotStream()
a.stopWebcamStream()
a.disconnectWS()
guard.Stop()
}
func (a *App) isMonitoringActive() bool {
a.mu.Lock()
defer a.mu.Unlock()
return !a.monitoringTornDown
}
// showQuitDialog — ngắt giám sát trước, hiện cảnh báo, sinh viên bấm OK rồi app mới thoát.
func (a *App) showQuitDialog(title, message string) {
a.mu.Lock()
if a.quitDialogShown {
a.mu.Unlock()
return
}
a.quitDialogShown = true
a.mu.Unlock()
a.tearDownBeforeQuit()
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.WarningDialog,
Title: title,
Message: message + "\n\nNhấn OK để đóng ứng dụng.",
})
runtime.Quit(a.ctx)
}
// 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()
mux.HandleFunc("/login-success", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
return
}
data := r.URL.Query().Get("data")
if data == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
a.mu.Lock()
expecting := a.expectingLogin
a.mu.Unlock()
if !expecting {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ignored"))
return
}
var st StudentData
if err := json.Unmarshal([]byte(data), &st); err == nil && st.StudentID > 0 {
a.saveSession(&st)
log.Printf("[LOCALSERVER] Student %s logged in successfully.", st.FullName)
a.mu.Lock()
a.expectingLogin = false
a.mu.Unlock()
// Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện
go a.refreshStudentData()
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
w.Write([]byte("ok"))
} else {
w.WriteHeader(http.StatusBadRequest)
}
})
server := &http.Server{
Addr: "127.0.0.1:34115",
Handler: mux,
}
mux.HandleFunc("/exam-view", func(w http.ResponseWriter, r *http.Request) {
target := strings.TrimSpace(r.URL.Query().Get("url"))
title := strings.TrimSpace(r.URL.Query().Get("title"))
if target == "" {
http.Error(w, "missing url", http.StatusBadRequest)
return
}
if title == "" {
title = "Xem trong Simple Care"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprintf(w, examViewHTML, html.EscapeString(title), html.EscapeString(title), html.EscapeString(target))
})
mux.HandleFunc("/exam-close", 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, OPTIONS")
return
}
go func() {
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}()
w.Write([]byte("ok"))
})
go func() {
_ = server.ListenAndServe()
}()
log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115")
}
const examViewHTML = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
.bar button{background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;font-weight:600}
.bar button:hover{background:#6d28d9}
.bar span{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frame-wrap{flex:1;min-height:0;background:#fff}
iframe{width:100%%;height:100%%;border:0;display:block}
</style>
</head>
<body>
<div class="bar">
<button type="button" onclick="goBack()">← Quay lại</button>
<span>%s</span>
</div>
<div class="frame-wrap">
<iframe src="%s" title="exam-content"></iframe>
</div>
<script>
function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
}
</script>
</body>
</html>`
// loadSession nạp session từ file cục bộ
func (a *App) loadSession() {
a.mu.Lock()
defer a.mu.Unlock()
data, err := os.ReadFile(a.sessionPath)
if err == nil {
decrypted, errDec := decrypt(data)
if errDec == nil {
data = decrypted
}
var s StudentData
if err := json.Unmarshal(data, &s); err == nil {
a.student = &s
log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode)
go a.refreshStudentData()
go a.fetchWifiPolicy()
go a.syncProfileToServer(&s)
}
}
}
// saveSession lưu session ra file
func (a *App) saveSession(s *StudentData) {
a.mu.Lock()
defer a.mu.Unlock()
a.student = s
data, _ := json.MarshalIndent(s, "", " ")
encrypted, err := encrypt(data)
if err == nil {
_ = os.WriteFile(a.sessionPath, encrypted, 0644)
} else {
_ = os.WriteFile(a.sessionPath, data, 0644)
}
go a.syncProfileToServer(s)
}
func (a *App) syncProfileToServer(s *StudentData) {
if s == nil || s.Avatar == "" {
return
}
payload := map[string]any{
"studentRkId": s.StudentID,
"classRkId": s.SystemID,
"sessionDate": time.Now().Format("2006-01-02"),
"avatar": s.Avatar,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Post(API_BASE+"/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
_ = resp.Body.Close()
}
// loadStats nạp stats
func (a *App) loadStats() {
a.mu.Lock()
defer a.mu.Unlock()
data, err := os.ReadFile(a.statsPath)
if err == nil {
var ls LocalStats
if err := json.Unmarshal(data, &ls); err == nil {
a.onlineSecs = ls.OnlineSecs
a.offlineSecs = ls.OfflineSecs
a.unsyncedOn = ls.UnsyncedOn
a.unsyncedOff = ls.UnsyncedOff
}
}
}
// saveStats lưu stats ra file
func (a *App) saveStats() {
a.mu.Lock()
defer a.mu.Unlock()
ls := LocalStats{
OnlineSecs: a.onlineSecs,
OfflineSecs: a.offlineSecs,
UnsyncedOn: a.unsyncedOn,
UnsyncedOff: a.unsyncedOff,
}
data, _ := json.Marshal(ls)
_ = os.WriteFile(a.statsPath, data, 0644)
}
// CheckLoginStatus kiểm tra xem sinh viên đã đăng nhập chưa
func (a *App) CheckLoginStatus() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.student != nil
}
// GetStudentInfo trả về thông tin sinh viên
func (a *App) GetStudentInfo() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
if a.student == nil {
return nil
}
return map[string]any{
"studentId": a.student.StudentID,
"fullName": a.student.FullName,
"email": a.student.Email,
"avatar": a.student.Avatar,
"dob": a.student.Dob,
"studentCode": a.student.StudentCode,
"phone": a.student.Phone,
"systemId": a.student.SystemID,
}
}
// NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal
func (a *App) NavigateToLogin() {
a.mu.Lock()
a.expectingLogin = true
a.mu.Unlock()
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
}
// Logout đăng xuất sinh viên
func (a *App) Logout() {
a.mu.Lock()
a.student = nil
a.expectingLogin = true
a.needsClearPortalStorage = true
a.mu.Unlock()
_ = os.Remove(a.sessionPath)
_ = os.Remove(a.statsPath)
a.mu.Lock()
a.onlineSecs = 0
a.offlineSecs = 0
a.unsyncedOn = 0
a.unsyncedOff = 0
a.mu.Unlock()
blocker.Instance.Stop()
a.disconnectWS()
a.stopScreenshotStream()
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
}
// GetStats trả về Wifi, thời gian online/offline và trạng thái giám sát
func (a *App) GetStats() map[string]any {
a.mu.Lock()
defer a.mu.Unlock()
shifts := make([]StudentShiftSnapshot, len(a.dashboard.Shifts))
copy(shifts, a.dashboard.Shifts)
for i := range shifts {
if shifts[i].IsActiveNow {
shifts[i].OnlineSeconds += a.unsyncedOn
shifts[i].OfflineSeconds += a.unsyncedOff
}
}
return map[string]any{
"wifiSSID": a.wifiSSID,
"onlineSecs": a.onlineSecs,
"offlineSecs": a.offlineSecs,
"wsConnected": a.wsConnected,
"serverReachable": a.backendOnline,
"allowedApps": a.allowedApps,
"statusMsg": a.statusMsg,
"sessionDate": a.dashboard.SessionDate,
"monitorMode": a.dashboard.MonitorMode,
"monitorLabel": a.dashboard.MonitorLabel,
"className": a.dashboard.ClassName,
"classCode": a.dashboard.ClassCode,
"currentPeriod": a.dashboard.CurrentPeriod,
"currentCourseName": a.dashboard.CurrentCourseName,
"currentShiftStart": a.dashboard.CurrentShiftStart,
"currentShiftEnd": a.dashboard.CurrentShiftEnd,
"inScheduleNow": a.dashboard.InScheduleNow,
"blockerActive": a.dashboard.BlockerActive,
"shifts": shifts,
"exam": a.dashboard.Exam,
}
}
// safeWriteWS writes a message to the WebSocket connection thread-safely
func (a *App) safeWriteWS(msg any) error {
a.mu.Lock()
conn := a.wsConn
a.mu.Unlock()
if conn == nil {
return errors.New("websocket connection is nil")
}
a.wsWriteMu.Lock()
defer a.wsWriteMu.Unlock()
return conn.WriteJSON(msg)
}
// SendWebcamFrame truyền webcam frame từ JS frontend lên máy chủ qua WS
func (a *App) SendWebcamFrame(frameBase64 string) {
if !a.isMonitoringActive() {
return
}
_ = a.safeWriteWS(map[string]any{
"event": "webcam_stream_frame",
"data": map[string]any{
"imageBuffer": frameBase64,
},
})
}
// authStorageScanner chỉ quét localStorage khi người dùng chủ động mở trang đăng nhập
func (a *App) authStorageScanner() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
a.mu.Lock()
expecting := a.expectingLogin
needsClear := a.needsClearPortalStorage
loggedIn := a.student != nil
a.mu.Unlock()
if needsClear && expecting {
runtime.WindowExecJS(a.ctx, `
try {
localStorage.removeItem("student");
localStorage.removeItem("token");
} catch(e) {}
`)
a.mu.Lock()
a.needsClearPortalStorage = false
a.mu.Unlock()
continue
}
if loggedIn || !expecting {
continue
}
runtime.WindowExecJS(a.ctx, `
try {
const st = localStorage.getItem("student");
if (st && !window.__redirecting) {
window.__redirecting = true;
window.location.href = "http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st);
}
} catch(e) {}
`)
}
}
// monitorLoop định kỳ 10s: check wifi, ping backend, cập nhật online/offline seconds và đồng bộ lên server
func (a *App) monitorLoop() {
for {
a.runMonitorTick()
a.mu.Lock()
wifiEmpty := a.wifiSSID == "" || strings.Contains(a.wifiSSID, "Chưa cấp quyền") || strings.Contains(a.wifiSSID, "redacted")
expecting := a.expectingLogin
a.mu.Unlock()
sleepInterval := 10 * time.Second
if wifiEmpty && !expecting {
sleepInterval = 2 * time.Second
}
time.Sleep(sleepInterval)
}
}
func (a *App) runMonitorTick() {
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
return
}
wifi := winapi.GetWifiConnection()
a.mu.Lock()
if strings.Contains(strings.ToLower(wifi.SSID), "redacted") {
a.wifiSSID = "Chưa cấp quyền Vị Trí (macOS)"
} else {
a.wifiSSID = wifi.SSID
}
if strings.Contains(strings.ToLower(wifi.BSSID), "redacted") {
a.wifiBSSID = "Chưa cấp quyền Vị Trí (macOS)"
} else {
a.wifiBSSID = wifi.BSSID
}
a.mu.Unlock()
backendOnline := a.pingBackend()
if backendOnline && wifi.SSID != "" && wifi.BSSID != "" {
go a.reportWifi(wifi.SSID, wifi.BSSID)
a.fetchWifiPolicy()
if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) {
a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID)
return
}
}
a.mu.Lock()
a.backendOnline = backendOnline
if backendOnline {
a.onlineSecs += 10
a.unsyncedOn += 10
} else {
a.offlineSecs += 10
a.unsyncedOff += 10
}
a.mu.Unlock()
a.saveStats()
if backendOnline {
a.connectWS()
a.syncLogsToServer()
} else {
a.disconnectWS()
}
}
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
func (a *App) resolveClassRkID() int64 {
a.mu.Lock()
defer a.mu.Unlock()
if a.dashboard.ClassRkID > 0 {
return a.dashboard.ClassRkID
}
return 0
}
func (a *App) refreshStudentData() {
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil {
return
}
a.fetchStudentStatus(student.StudentID)
classID := a.resolveClassRkID()
if classID > 0 {
a.fetchAllowedApps(classID)
} else {
a.fetchAllowedApps(0)
}
a.refreshNetworkStatus()
}
// refreshNetworkStatus — đọc WiFi + ping server ngay (không cộng thời gian online/offline).
func (a *App) refreshNetworkStatus() {
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
return
}
wifi := winapi.GetWifiConnection()
backendOnline := a.pingBackend()
a.mu.Lock()
if strings.Contains(strings.ToLower(wifi.SSID), "redacted") {
a.wifiSSID = "Chưa cấp quyền Vị Trí (macOS)"
} else {
a.wifiSSID = wifi.SSID
}
if strings.Contains(strings.ToLower(wifi.BSSID), "redacted") {
a.wifiBSSID = "Chưa cấp quyền Vị Trí (macOS)"
} else {
a.wifiBSSID = wifi.BSSID
}
a.backendOnline = backendOnline
a.mu.Unlock()
if backendOnline {
a.connectWS()
}
}
func (a *App) pingBackend() bool {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(API_BASE + "/api/health")
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func (a *App) syncLogsToServer() {
if !a.isMonitoringActive() {
return
}
a.mu.Lock()
student := a.student
unsyncedOn := a.unsyncedOn
unsyncedOff := a.unsyncedOff
ssid := a.wifiSSID
a.mu.Unlock()
if student == nil {
return
}
go func() {
a.fetchStudentStatus(student.StudentID)
classID := a.resolveClassRkID()
if classID <= 0 {
classID = student.SystemID
}
a.fetchAllowedApps(classID)
}()
payload := map[string]any{
"studentRkId": student.StudentID,
"classRkId": a.resolveClassRkID(),
"sessionDate": time.Now().Format("2006-01-02"),
"addOnlineSeconds": unsyncedOn,
"addOfflineSeconds": unsyncedOff,
"wifiSsid": ssid,
"avatar": student.Avatar,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Post(API_BASE+"/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
a.mu.Lock()
a.unsyncedOn -= unsyncedOn
a.unsyncedOff -= unsyncedOff
a.mu.Unlock()
a.saveStats()
log.Printf("[SYNC] Synced %ds online, %ds offline to server.", unsyncedOn, unsyncedOff)
}
}
func (a *App) reportBlockedApp(procName string, title string) {
if !a.isMonitoringActive() {
return
}
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil || student.StudentID <= 0 {
return
}
procKey := strings.ToLower(strings.TrimSpace(procName))
if procKey == "" {
return
}
if v, ok := blockedReportLast.Load(procKey); ok {
if time.Since(v.(time.Time)) < 20*time.Second {
return
}
}
blockedReportLast.Store(procKey, time.Now())
payload := map[string]any{
"studentRkId": student.StudentID,
"processName": procName,
"windowTitle": title,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Post(API_BASE+"/api/student/report-blocked-app", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
log.Printf("[CLIENT] report-blocked-app failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("[CLIENT] report-blocked-app status %d: %s", resp.StatusCode, string(body))
return
}
log.Printf("[CLIENT] report-blocked-app ok: %s", procName)
}
func (a *App) reportWifi(ssid, bssid string) {
if !a.isMonitoringActive() {
return
}
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil || strings.TrimSpace(ssid) == "" || strings.TrimSpace(bssid) == "" {
return
}
key := winapi.BSSIDKey(bssid)
if len(key) != 12 {
return
}
if v, ok := wifiReportLast.Load(key); ok {
if time.Since(v.(time.Time)) < 30*time.Second {
return
}
}
wifiReportLast.Store(key, time.Now())
payload := map[string]any{
"studentRkId": student.StudentID,
"ssid": ssid,
"bssid": bssid,
}
bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Post(API_BASE+"/api/student/report-wifi", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil {
return
}
_ = resp.Body.Close()
}
func (a *App) fetchWifiPolicy() {
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get(API_BASE + "/api/student/wifi-policy")
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var res struct {
Enforce bool `json:"enforce"`
Accepted []struct {
SSID string `json:"ssid"`
BSSID string `json:"bssid"`
} `json:"accepted"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return
}
allowed := make(map[string]bool, len(res.Accepted))
for _, item := range res.Accepted {
k := winapi.BSSIDKey(item.BSSID)
if len(k) == 12 {
allowed[k] = true
}
}
a.mu.Lock()
a.wifiEnforce = res.Enforce
a.acceptedWifi = allowed
a.mu.Unlock()
}
func (a *App) isWifiAllowed(ssid, bssid string) bool {
a.mu.Lock()
enforce := a.wifiEnforce
allowed := a.acceptedWifi
a.mu.Unlock()
if !enforce || len(allowed) == 0 {
return true
}
// Bypass Wi-Fi check if it's redacted by macOS privacy controls
if strings.Contains(strings.ToLower(ssid), "redacted") || strings.Contains(strings.ToLower(bssid), "redacted") {
return true
}
bKey := winapi.BSSIDKey(bssid)
if bKey == "" || len(bKey) != 12 {
return false
}
return allowed[bKey]
}
func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
a.mu.Lock()
if a.wifiRejected {
a.mu.Unlock()
return
}
a.wifiRejected = true
a.mu.Unlock()
a.showQuitDialog(
"WiFi không được phép",
fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid),
)
}
func (a *App) fetchAllowedApps(classId int64) {
a.fetchAppsMu.Lock()
defer a.fetchAppsMu.Unlock()
a.mu.Lock()
studentID := int64(0)
if a.student != nil {
studentID = a.student.StudentID
}
a.mu.Unlock()
url := fmt.Sprintf("%s/api/classes/%d/allowed-apps?studentId=%d", API_BASE, classId, studentID)
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var res struct {
Keywords string `json:"keywords"`
Exit bool `json:"exit"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
if res.Exit {
a.mu.Lock()
stillExam := a.dashboard.MonitorMode == "exam"
a.mu.Unlock()
if stillExam {
return
}
reason := res.Reason
if reason == "" {
reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)."
}
log.Printf("[CLIENT] Conditions not met: %s. Blocker suspended.", reason)
a.mu.Lock()
a.allowedApps = ""
a.statusMsg = reason
a.mu.Unlock()
blocker.Instance.Stop()
return
}
a.mu.Lock()
a.allowedApps = res.Keywords
if a.dashboard.MonitorLabel != "" {
a.statusMsg = a.dashboard.MonitorLabel
} else {
a.statusMsg = "Đang trong giờ học"
}
a.mu.Unlock()
log.Printf("[CLIENT] Allowed apps fetched from server: %s", res.Keywords)
blocker.Instance.SetKeywords(res.Keywords)
blocker.Instance.Start()
}
}
}
func (a *App) fetchStudentStatus(studentID int64) {
if studentID <= 0 {
return
}
client := http.Client{Timeout: 4 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/api/student/status?studentRkId=%d", API_BASE, studentID))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var res StudentDashboardSnapshot
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return
}
a.mu.Lock()
prevMode := a.dashboard.MonitorMode
a.dashboard = res
if res.MonitorLabel != "" {
a.statusMsg = res.MonitorLabel
}
if res.ClassName != "" && res.MonitorMode == "learning" {
a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel)
} else if res.ClassName != "" && res.MonitorMode == "outside_schedule" {
a.statusMsg = fmt.Sprintf("%s · Ngoài giờ học", res.ClassName)
} else if res.ClassName != "" && res.MonitorMode == "not_configured" {
a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel)
} else if res.MonitorMode == "exam" {
a.statusMsg = res.MonitorLabel
}
a.mu.Unlock()
if prevMode != res.MonitorMode {
guard.SuppressFor(4 * time.Second)
}
}
func (a *App) connectWS() {
if !a.CheckLoginStatus() {
return
}
a.mu.Lock()
if a.wsConnected || a.student == nil {
a.mu.Unlock()
return
}
student := a.student
classID := a.dashboard.ClassRkID
if classID <= 0 {
classID = student.SystemID
}
a.mu.Unlock()
wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID)
dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second}
conn, _, err := dialer.Dial(wsUrl, nil)
if err != nil {
return
}
a.mu.Lock()
a.wsConn = conn
a.wsConnected = true
a.mu.Unlock()
log.Println("[WS] Connected to proctor websocket hub.")
go func() {
defer a.disconnectWS()
for {
var msg struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
err := conn.ReadJSON(&msg)
if err != nil {
log.Printf("[WS] ReadJSON error: %v", err)
break
}
log.Printf("[WS] Received event: %s", msg.Event)
switch msg.Event {
case "start_screenshot_stream":
a.startScreenshotStream()
case "stop_screenshot_stream":
a.stopScreenshotStream()
case "start_webcam_stream":
a.startWebcamStream()
case "stop_webcam_stream":
a.stopWebcamStream()
case "chat:message":
senderRole, _ := msg.Data["senderRole"].(string)
if senderRole == "staff" {
from := "Giảng viên"
if n, ok := msg.Data["staffName"].(string); ok && n != "" {
from = n
}
preview := ""
if b, ok := msg.Data["body"].(string); ok {
preview = b
}
if staffVal, ok := msg.Data["staffId"].(float64); ok {
a.mu.Lock()
a.replyStaffID = uint(staffVal)
a.chatUnread++
a.mu.Unlock()
}
a.alertChatIncoming(from, preview)
}
runtime.EventsEmit(a.ctx, "chat:message", msg.Data)
case "exam:paper-sent":
runtime.EventsEmit(a.ctx, "exam:paper-sent", msg.Data)
a.mu.Lock()
if a.dashboard.Exam != nil {
a.dashboard.Exam.PaperSent = true
if t, ok := msg.Data["paperTitle"].(string); ok {
a.dashboard.Exam.PaperTitle = t
}
}
a.mu.Unlock()
}
}
}()
}
func (a *App) disconnectWS() {
a.mu.Lock()
defer a.mu.Unlock()
if a.wsConn != nil {
_ = a.wsConn.Close()
a.wsConn = nil
}
a.wsConnected = false
}
func (a *App) startScreenshotStream() {
a.mu.Lock()
if a.isStreamingSc {
a.mu.Unlock()
return
}
a.isStreamingSc = true
a.streamScStop = make(chan struct{})
a.mu.Unlock()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
frame, err := screen.CaptureScreen()
if err != nil {
continue
}
_ = a.safeWriteWS(map[string]any{
"event": "screenshot_stream_frame",
"data": map[string]any{
"imageBuffer": frame,
},
})
case <-a.streamScStop:
return
}
}
}()
log.Println("[WS] Screenshot screen-streaming started.")
}
func (a *App) stopScreenshotStream() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.isStreamingSc {
return
}
a.isStreamingSc = false
close(a.streamScStop)
log.Println("[WS] Screenshot screen-streaming stopped.")
}
func (a *App) startWebcamStream() {
a.mu.Lock()
if a.isStreamingCam {
a.mu.Unlock()
return
}
a.isStreamingCam = true
a.streamCamStop = make(chan struct{})
a.mu.Unlock()
if !camera.IsNative {
log.Println("[WS] Platform is non-darwin, delegating webcam stream to frontend events")
runtime.EventsEmit(a.ctx, "start_webcam_stream")
return
}
if err := camera.StartCapture(); err != nil {
log.Printf("[WS] Failed to start native camera: %v", err)
a.mu.Lock()
a.isStreamingCam = false
a.mu.Unlock()
return
}
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
emptyCount := 0
sentCount := 0
for {
select {
case <-ticker.C:
frame := camera.GetFrame()
if frame == "" {
emptyCount++
if emptyCount <= 20 || emptyCount%100 == 0 {
log.Printf("[WS] Webcam: no frame available (empty count: %d)", emptyCount)
}
continue
}
emptyCount = 0
sentCount++
err := a.safeWriteWS(map[string]any{
"event": "webcam_stream_frame",
"data": map[string]any{
"imageBuffer": frame,
},
})
if sentCount <= 5 {
log.Printf("[WS] Webcam frame #%d sent (len=%d, err=%v)", sentCount, len(frame), err)
}
case <-a.streamCamStop:
return
}
}
}()
log.Println("[WS] Webcam native streaming started.")
}
func (a *App) stopWebcamStream() {
a.mu.Lock()
defer a.mu.Unlock()
if !a.isStreamingCam {
return
}
a.isStreamingCam = false
if a.streamCamStop != nil {
close(a.streamCamStop)
}
if !camera.IsNative {
log.Println("[WS] Platform is non-darwin, stopping frontend webcam stream via event")
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
return
}
camera.StopCapture()
log.Println("[WS] Webcam native streaming stopped.")
}
func (a *App) alertChatIncoming(from, preview string) {
if preview == "" {
preview = "Bạn có tin nhắn mới"
}
if len(preview) > 120 {
preview = preview[:120] + "…"
}
winapi.PlayNotifySound()
winapi.ActivateAppWindow("Simple Care")
runtime.WindowUnminimise(a.ctx)
runtime.WindowShow(a.ctx)
a.mu.Lock()
unread := a.chatUnread
staffID := a.replyStaffID
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "chat:notify", map[string]any{
"unread": unread,
"preview": preview,
"from": from,
"staffId": staffID,
})
}
// UnlockChatAudio — gọi từ UI sau lần click đầu để mở khóa Web Audio (dự phòng)
func (a *App) UnlockChatAudio() {}
func (a *App) GetChatUnread() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.chatUnread
}
func (a *App) ClearChatUnread() {
a.mu.Lock()
a.chatUnread = 0
a.mu.Unlock()
}
func (a *App) GetChatConversations() ([]map[string]any, error) {
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil {
return nil, errors.New("chưa đăng nhập")
}
url := fmt.Sprintf("%s/api/student/chat/conversations?studentRkId=%d", API_BASE, student.StudentID)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
Data []map[string]any `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
return payload.Data, nil
}
func (a *App) GetChatMessages(staffID uint) ([]map[string]any, error) {
a.mu.Lock()
student := a.student
a.mu.Unlock()
if student == nil {
return nil, errors.New("chưa đăng nhập")
}
url := fmt.Sprintf("%s/api/student/chat/messages?studentRkId=%d&staffId=%d", API_BASE, student.StudentID, staffID)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
Data []map[string]any `json:"data"`
ReplyStaffID uint `json:"replyStaffId"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
if staffID > 0 {
a.mu.Lock()
a.replyStaffID = staffID
a.mu.Unlock()
} else if payload.ReplyStaffID > 0 {
a.mu.Lock()
a.replyStaffID = payload.ReplyStaffID
a.mu.Unlock()
}
a.ClearChatUnread()
return payload.Data, nil
}
func (a *App) SendChatMessage(body string) error {
body = strings.TrimSpace(body)
if body == "" {
return errors.New("tin nhắn trống")
}
a.mu.Lock()
student := a.student
staffID := a.replyStaffID
a.mu.Unlock()
if student == nil {
return errors.New("chưa đăng nhập")
}
payload := map[string]any{
"studentRkId": student.StudentID,
"staffId": staffID,
"body": body,
}
b, _ := json.Marshal(payload)
resp, err := http.Post(API_BASE+"/api/student/chat/messages", "application/json", bytes.NewReader(b))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var errBody map[string]any
_ = json.NewDecoder(resp.Body).Decode(&errBody)
if msg, ok := errBody["error"].(string); ok {
return errors.New(msg)
}
return fmt.Errorf("gửi tin thất bại (%d)", resp.StatusCode)
}
return nil
}
func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.student == nil {
return 0, nil, errors.New("chưa đăng nhập")
}
if a.dashboard.Exam == nil {
return 0, nil, errors.New("không trong giờ thi")
}
return a.student.StudentID, a.dashboard.Exam, nil
}
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
func (a *App) ReturnToDashboard() {
guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx)
}
func isLocalExamURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
return host == "127.0.0.1" || host == "localhost"
}
func (a *App) openExamWebView(targetURL, title string) error {
targetURL = strings.TrimSpace(targetURL)
if targetURL == "" {
return errors.New("không có nội dung để mở")
}
if isLocalExamURL(targetURL) {
wrapper := fmt.Sprintf(
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
url.QueryEscape(targetURL),
url.QueryEscape(title),
)
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
return nil
}
// Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập).
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return nil
}
func (a *App) GetExamPaperViewURL() (string, error) {
studentID, exam, err := a.examStudentContext()
if err != nil {
return "", err
}
if !exam.PaperSent {
return "", errors.New("Giảng viên chưa gửi đề")
}
return fmt.Sprintf("%s/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", API_BASE, studentID), nil
}
func (a *App) GetExamQuizViewURL() (string, error) {
_, exam, err := a.examStudentContext()
if err != nil {
return "", err
}
quizURL := strings.TrimSpace(exam.QuizURL)
if quizURL == "" {
return "", errors.New("Phòng thi chưa cấu hình link trắc nghiệm")
}
return quizURL, nil
}
func (a *App) GetExamPaperFiles() (map[string]any, error) {
studentID, exam, err := a.examStudentContext()
if err != nil {
return nil, err
}
if !exam.PaperSent {
return nil, errors.New("Giảng viên chưa gửi đề")
}
apiURL := fmt.Sprintf("%s/api/student/exam/paper-files?studentRkId=%d", API_BASE, studentID)
resp, err := http.Get(apiURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("không tải được danh sách tài nguyên")
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
base := API_BASE
if pdf, ok := payload["pdfUrl"].(string); ok && pdf != "" && !strings.HasPrefix(pdf, "http") {
payload["pdfUrl"] = base + pdf
}
if raw, ok := payload["resources"].([]any); ok {
for i, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if u, ok := m["url"].(string); ok && u != "" && !strings.HasPrefix(u, "http") {
m["url"] = base + u
}
if u, ok := m["downloadUrl"].(string); ok && u != "" && !strings.HasPrefix(u, "http") {
m["downloadUrl"] = base + u
}
raw[i] = m
}
payload["resources"] = raw
}
return payload, nil
}
func (a *App) OpenExamPaper() error {
viewURL, err := a.GetExamPaperViewURL()
if err != nil {
return err
}
title := "Đề thi"
a.mu.Lock()
if a.dashboard.Exam != nil && strings.TrimSpace(a.dashboard.Exam.PaperTitle) != "" {
title = strings.TrimSpace(a.dashboard.Exam.PaperTitle)
}
a.mu.Unlock()
return a.openExamWebView(viewURL, title)
}
func (a *App) OpenExamQuiz() error {
viewURL, err := a.GetExamQuizViewURL()
if err != nil {
return err
}
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
}
func (a *App) OpenExamResource(fileID uint) error {
studentID, _, err := a.examStudentContext()
if err != nil {
return err
}
if fileID == 0 {
return errors.New("tài nguyên không hợp lệ")
}
viewURL := fmt.Sprintf(
"%s/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1",
API_BASE, studentID, fileID,
)
title := "Tài nguyên đề thi"
files, err := a.GetExamPaperFiles()
if err == nil {
if raw, ok := files["resources"].([]any); ok {
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
id, _ := m["id"].(float64)
if uint(id) == fileID {
if name, ok := m["fileName"].(string); ok && name != "" {
title = name
}
break
}
}
}
}
return a.openExamWebView(viewURL, title)
}
func examResourceDownloadDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
candidates := []string{
filepath.Join(home, "Downloads"),
filepath.Join(home, "Desktop"),
home,
}
for _, dir := range candidates {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir, nil
}
}
return "", errors.New("không tìm thấy thư mục Downloads hoặc Desktop")
}
func uniqueFilePath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
dir := filepath.Dir(path)
for i := 1; i < 100; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, time.Now().Unix(), ext))
}
func openFolderInExplorer(filePath string) {
guard.SuppressFor(5 * time.Second)
dir := filepath.Dir(filePath)
_ = exec.Command("explorer", dir).Start()
}
func (a *App) DownloadExamResource(fileID uint) (string, error) {
studentID, _, err := a.examStudentContext()
if err != nil {
return "", err
}
if fileID == 0 {
return "", errors.New("tài nguyên không hợp lệ")
}
fileName := fmt.Sprintf("resource_%d", fileID)
files, err := a.GetExamPaperFiles()
if err == nil {
if raw, ok := files["resources"].([]any); ok {
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
id, _ := m["id"].(float64)
if uint(id) == fileID {
if name, ok := m["fileName"].(string); ok && name != "" {
fileName = filepath.Base(name)
}
break
}
}
}
}
dlURL := fmt.Sprintf(
"%s/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d",
API_BASE, studentID, fileID,
)
resp, err := http.Get(dlURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", errors.New("không tải được tài nguyên")
}
dir, err := examResourceDownloadDir()
if err != nil {
return "", err
}
dest := uniqueFilePath(filepath.Join(dir, fileName))
f, err := os.Create(dest)
if err != nil {
return "", err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
return "", err
}
f.Close()
openFolderInExplorer(dest)
return dest, nil
}
// LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView).
func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) {
fileURL = strings.TrimSpace(fileURL)
if !strings.HasPrefix(fileURL, API_BASE + "/api/student/exam/download") {
return nil, errors.New("URL không hợp lệ")
}
if _, _, err := a.examStudentContext(); err != nil {
return nil, err
}
resp, err := http.Get(fileURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("không tải được file")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
mime := strings.TrimSpace(resp.Header.Get("Content-Type"))
if mime == "" {
mime = "application/octet-stream"
}
// Bỏ charset nếu có
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
return map[string]any{
"data": base64.StdEncoding.EncodeToString(body),
"mime": mime,
}, nil
}
func (a *App) SubmitExamWork() (string, error) {
a.mu.Lock()
student := a.student
exam := a.dashboard.Exam
a.mu.Unlock()
if student == nil {
return "", errors.New("chưa đăng nhập")
}
if exam == nil {
return "", errors.New("không trong giờ thi")
}
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Chọn folder bài làm để nộp",
})
if err != nil {
return "", err
}
if dir == "" {
return "", errors.New("đã hủy")
}
zipPath := filepath.Join(os.TempDir(), fmt.Sprintf("exam_submit_%d_%d.zip", exam.ExamRoomID, time.Now().Unix()))
if err := zipFolder(dir, zipPath); err != nil {
return "", err
}
defer os.Remove(zipPath)
f, err := os.Open(zipPath)
if err != nil {
return "", err
}
defer f.Close()
var body bytes.Buffer
w := multipart.NewWriter(&body)
_ = w.WriteField("studentRkId", fmt.Sprintf("%d", student.StudentID))
part, err := w.CreateFormFile("submission", filepath.Base(zipPath))
if err != nil {
return "", err
}
if _, err := io.Copy(part, f); err != nil {
return "", err
}
_ = w.Close()
req, err := http.NewRequest("POST", API_BASE + "/api/student/exam/submit", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var res struct {
OK bool `json:"ok"`
FileName string `json:"fileName"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&res)
if resp.StatusCode >= 300 || !res.OK {
if res.Error != "" {
return "", errors.New(res.Error)
}
return "", errors.New("nộp bài thất bại")
}
a.mu.Lock()
if a.dashboard.Exam != nil {
a.dashboard.Exam.Submitted = true
}
a.mu.Unlock()
return res.FileName, nil
}
func zipFolder(srcDir, destZip string) error {
out, err := os.Create(destZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Name = rel
hdr.Method = zip.Deflate
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
rf, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(w, rf)
rf.Close()
return copyErr
})
}