This commit is contained in:
490
client/app.go
490
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 = `<!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 {
|
||||
|
||||
Reference in New Issue
Block a user