tam 2
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s

This commit is contained in:
2026-07-13 09:04:09 +07:00
parent 719704f19e
commit 8803cd622a
22 changed files with 1322 additions and 121 deletions

View File

@@ -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 = `<!DOCTYPE html>
<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}
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
.btn-back{background:#7c3aed;color:#fff}
.btn-back:hover{background:#6d28d9}
.btn-refresh{background:#0ea5e9;color:#fff}
.btn-refresh:hover{background:#0284c7}
.btn-cache{background:#f59e0b;color:#111}
.btn-cache:hover{background:#d97706}
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
.hint{font-size:11px;color:#94a3b8;flex-shrink:0}
.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>
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
<span class="bar-title">%s</span>
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span>
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
</div>
<div class="frame-wrap">
<iframe src="%s" title="exam-content"></iframe>
<iframe id="exam-frame" src="%s" title="exam-content"></iframe>
</div>
<script>
function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
}
function refreshExam(){
var f = document.getElementById('exam-frame');
if (!f) return;
var btn = document.getElementById('btn-refresh');
if (btn) btn.disabled = true;
try {
f.contentWindow.location.reload();
} catch (e) {
var src = f.src;
f.src = 'about:blank';
setTimeout(function(){ f.src = src; }, 50);
}
setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
}
async function clearExamCache(){
var btn = document.getElementById('btn-cache');
if (btn) btn.disabled = true;
try {
if (window.caches) {
var keys = await caches.keys();
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
}
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame');
if (f && f.contentWindow) {
try { f.contentWindow.localStorage.clear(); } catch (e) {}
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
try {
if (f.contentWindow.caches) {
var ikeys = await f.contentWindow.caches.keys();
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
}
} catch (e) {}
}
} catch (e) {}
try {
await fetch('http://127.0.0.1:34115/exam-clear-cache');
} catch (e) {}
var f = document.getElementById('exam-frame');
if (f) {
try {
var u = new URL(f.src, window.location.href);
u.searchParams.set('_sc_cb', String(Date.now()));
f.src = u.toString();
} catch (e) {
refreshExam();
}
}
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
}
document.addEventListener('keydown', function(e){
if (e.key === 'F5') {
e.preventDefault();
refreshExam();
}
});
</script>
</body>
</html>`
@@ -1017,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) {
a.wifiRejected = true
a.mu.Unlock()
a.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid))
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),
@@ -1132,40 +1404,101 @@ func (a *App) connectWS() {
return
}
a.mu.Lock()
if a.wsConnected || a.student == nil {
if a.wsConnected || a.wsConnecting || a.student == nil {
a.mu.Unlock()
return
}
a.wsConnecting = true
student := a.student
classID := a.dashboard.ClassRkID
if classID <= 0 {
classID = student.SystemID
}
backoff := a.wsBackoff
if backoff <= 0 {
backoff = 500 * time.Millisecond
}
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 {
a.mu.Lock()
a.wsConnecting = false
delay := backoff
next := delay * 2
if next > 10*time.Second {
next = 10 * time.Second
}
a.wsBackoff = next
a.mu.Unlock()
log.Printf("[WS] Dial failed: %v — retry in %v", err, delay)
time.AfterFunc(delay, func() {
if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS()
}
})
return
}
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(45 * time.Second))
})
a.mu.Lock()
a.wsConn = conn
a.wsConnected = true
a.wsConnecting = false
a.wsBackoff = 500 * time.Millisecond
a.mu.Unlock()
log.Println("[WS] Connected to proctor websocket hub.")
a.markRunLock()
pingDone := make(chan struct{})
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
a.wsWriteMu.Lock()
err := conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
a.wsWriteMu.Unlock()
if err != nil {
return
}
// App-level ping (một số proxy không forward WS control frames đúng).
_ = a.safeWriteWS(map[string]any{"event": "client:ping", "data": map[string]any{}})
}
}
}()
go func() {
defer close(pingDone)
defer func() {
a.disconnectWS(conn)
go func() {
time.Sleep(500 * time.Millisecond)
a.mu.Lock()
delay := a.wsBackoff
if delay <= 0 {
delay = 500 * time.Millisecond
}
next := delay * 2
if next > 10*time.Second {
next = 10 * time.Second
}
a.wsBackoff = next
a.mu.Unlock()
log.Printf("[WS] Disconnected — reconnect in %v", delay)
time.AfterFunc(delay, func() {
if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS()
}
}()
})
}()
for {
var msg struct {
@@ -1177,6 +1510,10 @@ func (a *App) connectWS() {
log.Printf("[WS] ReadJSON error: %v", err)
break
}
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
if msg.Event == "client:pong" {
continue
}
log.Printf("[WS] Received event: %s", msg.Event)
switch msg.Event {
@@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
a.wsConn = nil
}
a.wsConnected = false
a.wsConnecting = false
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
a.mu.Unlock()
@@ -1523,6 +1861,83 @@ func (a *App) ReturnToDashboard() {
runtime.WindowReloadApp(a.ctx)
}
// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm).
func (a *App) ReloadExamPage() {
guard.SuppressFor(3 * time.Second)
runtime.WindowExecJS(a.ctx, `
(function(){
try {
if (typeof window.refreshExam === 'function') { window.refreshExam(); return; }
var f = document.getElementById('exam-frame');
if (f) {
try { f.contentWindow.location.reload(); }
catch (e) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); }
return;
}
} catch (e) {}
location.reload();
})();
`)
}
// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi.
func (a *App) ClearExamBrowserCache() {
guard.SuppressFor(8 * time.Second)
a.purgeWebViewDiskCache()
runtime.WindowExecJS(a.ctx, `
(async function(){
try {
if (window.caches) {
var keys = await caches.keys();
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
}
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame');
if (f) {
try { f.contentWindow.localStorage.clear(); } catch (e) {}
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
try {
var u = new URL(f.src, window.location.href);
u.searchParams.set('_sc_cb', String(Date.now()));
f.src = u.toString();
return;
} catch (e) {
try { f.contentWindow.location.reload(); } catch (e2) {
var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50);
}
return;
}
}
} catch (e) {}
location.reload();
})();
`)
}
func (a *App) purgeWebViewDiskCache() {
if a.webviewDataPath == "" {
return
}
subs := []string{
filepath.Join("EBWebView", "Default", "Cache"),
filepath.Join("EBWebView", "Default", "Code Cache"),
filepath.Join("EBWebView", "Default", "GPUCache"),
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
}
for _, sub := range subs {
path := filepath.Join(a.webviewDataPath, sub)
if err := os.RemoveAll(path); err != nil {
log.Printf("[CACHE] purge %s: %v", path, err)
} else {
log.Printf("[CACHE] purged %s", path)
}
}
}
func isLocalExamURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
@@ -1533,11 +1948,17 @@ func isLocalExamURL(raw string) bool {
}
func (a *App) openExamWebView(targetURL, title string) error {
return a.openExamWebViewOpts(targetURL, title, false)
}
// openExamWebViewOpts mở nội dung trong WebView.
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
targetURL = strings.TrimSpace(targetURL)
if targetURL == "" {
return errors.New("không có nội dung để mở")
}
if isLocalExamURL(targetURL) {
if forceWrap || isLocalExamURL(targetURL) {
wrapper := fmt.Sprintf(
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
url.QueryEscape(targetURL),
@@ -1547,7 +1968,7 @@ func (a *App) openExamWebView(targetURL, title string) error {
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).
// Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return nil
@@ -1639,7 +2060,8 @@ func (a *App) OpenExamQuiz() error {
if err != nil {
return err
}
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
// Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi.
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true)
}
func (a *App) OpenExamResource(fileID uint) error {