1006 lines
24 KiB
Go
1006 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"client/internal/blocker"
|
|
"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 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
|
|
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{}
|
|
statusMsg string
|
|
expectingLogin bool
|
|
backendOnline bool
|
|
dashboard StudentDashboardSnapshot
|
|
wifiEnforce bool
|
|
acceptedWifi map[string]bool
|
|
wifiRejected bool
|
|
quitDialogShown bool
|
|
monitoringTornDown bool
|
|
}
|
|
|
|
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 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"`
|
|
}
|
|
|
|
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")
|
|
_ = os.MkdirAll(appDir, 0755)
|
|
|
|
return &App{
|
|
sessionPath: filepath.Join(appDir, "student_session.json"),
|
|
statsPath: filepath.Join(appDir, "student_stats.json"),
|
|
lastSyncTime: time.Now(),
|
|
expectingLogin: false,
|
|
}
|
|
}
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
a.loadSession()
|
|
a.loadStats()
|
|
|
|
// Register blocker callbacks
|
|
blocker.Instance.OnBlocked = func(procName string, title string) {
|
|
go a.reportBlockedApp(procName, title)
|
|
}
|
|
blocker.Instance.OnKill = func(procName string, title string) {
|
|
_, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
|
|
Type: runtime.WarningDialog,
|
|
Title: "Ứng dụng bị đóng",
|
|
Message: 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()
|
|
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
|
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()
|
|
|
|
blocker.Instance.Start()
|
|
|
|
// 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.fetchAllowedApps(st.SystemID)
|
|
go a.fetchStudentStatus(st.StudentID)
|
|
go a.fetchWifiPolicy()
|
|
go a.refreshNetworkStatus()
|
|
|
|
// Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets
|
|
runtime.WindowReloadApp(a.ctx)
|
|
w.Write([]byte("ok"))
|
|
} else {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
server := &http.Server{
|
|
Addr: "127.0.0.1:34115",
|
|
Handler: mux,
|
|
}
|
|
|
|
go func() {
|
|
_ = server.ListenAndServe()
|
|
}()
|
|
log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115")
|
|
}
|
|
|
|
// 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)
|
|
blocker.Instance.Start()
|
|
|
|
go a.fetchAllowedApps(s.SystemID)
|
|
go a.fetchStudentStatus(s.StudentID)
|
|
go a.fetchWifiPolicy()
|
|
go a.refreshNetworkStatus()
|
|
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("http://127.0.0.1:8080/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()
|
|
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 = false
|
|
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()
|
|
|
|
runtime.WindowReloadApp(a.ctx)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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.mu.Lock()
|
|
conn := a.wsConn
|
|
a.mu.Unlock()
|
|
|
|
if conn != nil {
|
|
_ = conn.WriteJSON(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
|
|
loggedIn := a.student != nil
|
|
a.mu.Unlock()
|
|
|
|
if loggedIn || !expecting {
|
|
continue
|
|
}
|
|
|
|
runtime.WindowExecJS(a.ctx, `
|
|
try {
|
|
const st = localStorage.getItem("student");
|
|
if (st) {
|
|
fetch("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() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
a.runMonitorTick()
|
|
|
|
for {
|
|
<-ticker.C
|
|
a.runMonitorTick()
|
|
}
|
|
}
|
|
|
|
func (a *App) runMonitorTick() {
|
|
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
|
|
return
|
|
}
|
|
|
|
wifi := winapi.GetWifiConnection()
|
|
a.mu.Lock()
|
|
a.wifiSSID = wifi.SSID
|
|
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) refreshNetworkStatus() {
|
|
if !a.isMonitoringActive() || !a.CheckLoginStatus() {
|
|
return
|
|
}
|
|
wifi := winapi.GetWifiConnection()
|
|
backendOnline := a.pingBackend()
|
|
a.mu.Lock()
|
|
a.wifiSSID = wifi.SSID
|
|
a.wifiBSSID = wifi.BSSID
|
|
a.backendOnline = backendOnline
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *App) pingBackend() bool {
|
|
client := http.Client{Timeout: 3 * time.Second}
|
|
resp, err := client.Get("http://127.0.0.1:8080/api/stats")
|
|
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 a.fetchStudentStatus(student.StudentID)
|
|
go a.fetchAllowedApps(student.SystemID)
|
|
|
|
payload := map[string]any{
|
|
"studentRkId": student.StudentID,
|
|
"classRkId": student.SystemID,
|
|
"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("http://127.0.0.1:8080/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("http://127.0.0.1:8080/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("http://127.0.0.1:8080/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("http://127.0.0.1:8080/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
|
|
}
|
|
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.mu.Lock()
|
|
studentID := int64(0)
|
|
if a.student != nil {
|
|
studentID = a.student.StudentID
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
client := http.Client{Timeout: 4 * time.Second}
|
|
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID))
|
|
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 {
|
|
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()
|
|
|
|
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("http://127.0.0.1:8080/api/student/status?studentRkId=%d", 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()
|
|
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)
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *App) connectWS() {
|
|
if !a.isMonitoringActive() {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
if a.wsConnected || a.student == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
student := a.student
|
|
a.mu.Unlock()
|
|
|
|
wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID)
|
|
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 {
|
|
break
|
|
}
|
|
|
|
switch msg.Event {
|
|
case "start_screenshot_stream":
|
|
a.startScreenshotStream()
|
|
case "stop_screenshot_stream":
|
|
a.stopScreenshotStream()
|
|
case "start_webcam_stream":
|
|
runtime.EventsEmit(a.ctx, "start_webcam_stream")
|
|
case "stop_webcam_stream":
|
|
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
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.mu.Lock()
|
|
conn := a.wsConn
|
|
a.mu.Unlock()
|
|
|
|
if conn != nil {
|
|
_ = conn.WriteJSON(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.")
|
|
}
|