Compare commits
6 Commits
c3bea3bb46
...
d94c1733be
| Author | SHA1 | Date | |
|---|---|---|---|
| d94c1733be | |||
| 44888520cb | |||
| 0d1e240227 | |||
| b702287cfb | |||
| 248d3a6722 | |||
| 35d954ff0f |
BIN
client/.DS_Store
vendored
Normal file
BIN
client/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -16,4 +16,18 @@ to this in your browser, and you can call your Go code from devtools.
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `wails build`.
|
### Windows
|
||||||
|
To build for Windows:
|
||||||
|
- Run `wails build` (or `wails build -platform windows/amd64`)
|
||||||
|
- Or use the PowerShell build script: `./build.ps1`
|
||||||
|
|
||||||
|
### macOS (ARM & Intel)
|
||||||
|
To build for macOS:
|
||||||
|
- To build Apple Silicon (ARM64) target: `wails build -platform darwin/arm64`
|
||||||
|
- To build Intel (AMD64) target: `wails build -platform darwin/amd64`
|
||||||
|
- To build a Universal macOS binary: `wails build -platform darwin/universal`
|
||||||
|
- Alternatively, run the helper script to build both ARM64 and AMD64 targets:
|
||||||
|
```bash
|
||||||
|
chmod +x build.sh
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|||||||
175
client/app.go
175
client/app.go
@@ -25,6 +25,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"client/internal/blocker"
|
"client/internal/blocker"
|
||||||
|
"client/internal/camera"
|
||||||
"client/internal/guard"
|
"client/internal/guard"
|
||||||
"client/internal/screen"
|
"client/internal/screen"
|
||||||
"client/internal/winapi"
|
"client/internal/winapi"
|
||||||
@@ -124,9 +125,12 @@ type App struct {
|
|||||||
lastSyncTime time.Time
|
lastSyncTime time.Time
|
||||||
isStreamingSc bool
|
isStreamingSc bool
|
||||||
streamScStop chan struct{}
|
streamScStop chan struct{}
|
||||||
statusMsg string
|
isStreamingCam bool
|
||||||
expectingLogin bool
|
streamCamStop chan struct{}
|
||||||
backendOnline bool
|
statusMsg string
|
||||||
|
expectingLogin bool
|
||||||
|
needsClearPortalStorage bool
|
||||||
|
backendOnline bool
|
||||||
dashboard StudentDashboardSnapshot
|
dashboard StudentDashboardSnapshot
|
||||||
wifiEnforce bool
|
wifiEnforce bool
|
||||||
acceptedWifi map[string]bool
|
acceptedWifi map[string]bool
|
||||||
@@ -209,6 +213,16 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.loadSession()
|
a.loadSession()
|
||||||
a.loadStats()
|
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
|
// Register blocker callbacks
|
||||||
blocker.Instance.OnBlocked = func(procName string, title string) {
|
blocker.Instance.OnBlocked = func(procName string, title string) {
|
||||||
go a.reportBlockedApp(procName, title)
|
go a.reportBlockedApp(procName, title)
|
||||||
@@ -257,7 +271,7 @@ func (a *App) tearDownBeforeQuit() {
|
|||||||
|
|
||||||
blocker.Instance.Stop()
|
blocker.Instance.Stop()
|
||||||
a.stopScreenshotStream()
|
a.stopScreenshotStream()
|
||||||
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
a.stopWebcamStream()
|
||||||
a.disconnectWS()
|
a.disconnectWS()
|
||||||
guard.Stop()
|
guard.Stop()
|
||||||
}
|
}
|
||||||
@@ -534,7 +548,8 @@ func (a *App) NavigateToLogin() {
|
|||||||
func (a *App) Logout() {
|
func (a *App) Logout() {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.student = nil
|
a.student = nil
|
||||||
a.expectingLogin = false
|
a.expectingLogin = true
|
||||||
|
a.needsClearPortalStorage = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
_ = os.Remove(a.sessionPath)
|
_ = os.Remove(a.sessionPath)
|
||||||
@@ -552,7 +567,7 @@ func (a *App) Logout() {
|
|||||||
a.stopScreenshotStream()
|
a.stopScreenshotStream()
|
||||||
|
|
||||||
guard.SuppressFor(5 * time.Second)
|
guard.SuppressFor(5 * time.Second)
|
||||||
runtime.WindowReloadApp(a.ctx)
|
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
|
// GetStats trả về Wifi, thời gian online/offline và trạng thái giám sát
|
||||||
@@ -622,9 +637,23 @@ func (a *App) authStorageScanner() {
|
|||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
expecting := a.expectingLogin
|
expecting := a.expectingLogin
|
||||||
|
needsClear := a.needsClearPortalStorage
|
||||||
loggedIn := a.student != nil
|
loggedIn := a.student != nil
|
||||||
a.mu.Unlock()
|
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 {
|
if loggedIn || !expecting {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -632,8 +661,9 @@ func (a *App) authStorageScanner() {
|
|||||||
runtime.WindowExecJS(a.ctx, `
|
runtime.WindowExecJS(a.ctx, `
|
||||||
try {
|
try {
|
||||||
const st = localStorage.getItem("student");
|
const st = localStorage.getItem("student");
|
||||||
if (st) {
|
if (st && !window.__redirecting) {
|
||||||
fetch("http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st));
|
window.__redirecting = true;
|
||||||
|
window.location.href = "http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st);
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
`)
|
`)
|
||||||
@@ -642,14 +672,20 @@ func (a *App) authStorageScanner() {
|
|||||||
|
|
||||||
// monitorLoop định kỳ 10s: check wifi, ping backend, cập nhật online/offline seconds và đồng bộ lên server
|
// 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() {
|
func (a *App) monitorLoop() {
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
a.runMonitorTick()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
<-ticker.C
|
|
||||||
a.runMonitorTick()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,8 +696,16 @@ func (a *App) runMonitorTick() {
|
|||||||
|
|
||||||
wifi := winapi.GetWifiConnection()
|
wifi := winapi.GetWifiConnection()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.wifiSSID = wifi.SSID
|
if strings.Contains(strings.ToLower(wifi.SSID), "redacted") {
|
||||||
a.wifiBSSID = wifi.BSSID
|
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()
|
a.mu.Unlock()
|
||||||
|
|
||||||
backendOnline := a.pingBackend()
|
backendOnline := a.pingBackend()
|
||||||
@@ -731,8 +775,16 @@ func (a *App) refreshNetworkStatus() {
|
|||||||
wifi := winapi.GetWifiConnection()
|
wifi := winapi.GetWifiConnection()
|
||||||
backendOnline := a.pingBackend()
|
backendOnline := a.pingBackend()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.wifiSSID = wifi.SSID
|
if strings.Contains(strings.ToLower(wifi.SSID), "redacted") {
|
||||||
a.wifiBSSID = wifi.BSSID
|
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.backendOnline = backendOnline
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
if backendOnline {
|
if backendOnline {
|
||||||
@@ -922,6 +974,10 @@ func (a *App) isWifiAllowed(ssid, bssid string) bool {
|
|||||||
if !enforce || len(allowed) == 0 {
|
if !enforce || len(allowed) == 0 {
|
||||||
return true
|
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)
|
bKey := winapi.BSSIDKey(bssid)
|
||||||
if bKey == "" || len(bKey) != 12 {
|
if bKey == "" || len(bKey) != 12 {
|
||||||
return false
|
return false
|
||||||
@@ -1000,6 +1056,7 @@ func (a *App) fetchAllowedApps(classId int64) {
|
|||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[CLIENT] Allowed apps fetched from server: %s", res.Keywords)
|
||||||
blocker.Instance.SetKeywords(res.Keywords)
|
blocker.Instance.SetKeywords(res.Keywords)
|
||||||
blocker.Instance.Start()
|
blocker.Instance.Start()
|
||||||
}
|
}
|
||||||
@@ -1086,8 +1143,10 @@ func (a *App) connectWS() {
|
|||||||
}
|
}
|
||||||
err := conn.ReadJSON(&msg)
|
err := conn.ReadJSON(&msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[WS] ReadJSON error: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
log.Printf("[WS] Received event: %s", msg.Event)
|
||||||
|
|
||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
case "start_screenshot_stream":
|
case "start_screenshot_stream":
|
||||||
@@ -1095,9 +1154,9 @@ func (a *App) connectWS() {
|
|||||||
case "stop_screenshot_stream":
|
case "stop_screenshot_stream":
|
||||||
a.stopScreenshotStream()
|
a.stopScreenshotStream()
|
||||||
case "start_webcam_stream":
|
case "start_webcam_stream":
|
||||||
runtime.EventsEmit(a.ctx, "start_webcam_stream")
|
a.startWebcamStream()
|
||||||
case "stop_webcam_stream":
|
case "stop_webcam_stream":
|
||||||
runtime.EventsEmit(a.ctx, "stop_webcam_stream")
|
a.stopWebcamStream()
|
||||||
case "chat:message":
|
case "chat:message":
|
||||||
senderRole, _ := msg.Data["senderRole"].(string)
|
senderRole, _ := msg.Data["senderRole"].(string)
|
||||||
if senderRole == "staff" {
|
if senderRole == "staff" {
|
||||||
@@ -1198,6 +1257,82 @@ func (a *App) stopScreenshotStream() {
|
|||||||
log.Println("[WS] Screenshot screen-streaming stopped.")
|
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 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++
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
conn := a.wsConn
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
err := conn.WriteJSON(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)
|
||||||
|
}
|
||||||
|
} else if sentCount <= 5 {
|
||||||
|
log.Printf("[WS] Webcam frame #%d: no WS connection", sentCount)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
close(a.streamCamStop)
|
||||||
|
camera.StopCapture()
|
||||||
|
log.Println("[WS] Webcam native streaming stopped.")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) alertChatIncoming(from, preview string) {
|
func (a *App) alertChatIncoming(from, preview string) {
|
||||||
if preview == "" {
|
if preview == "" {
|
||||||
preview = "Bạn có tin nhắn mới"
|
preview = "Bạn có tin nhắn mới"
|
||||||
|
|||||||
30
client/build.sh
Executable file
30
client/build.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Change directory to client folder if not already there
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "============================================="
|
||||||
|
echo " BUILDING WAILS CLIENT FOR MACOS "
|
||||||
|
echo "============================================="
|
||||||
|
|
||||||
|
# Check if wails is installed
|
||||||
|
WAILS_CMD="wails"
|
||||||
|
if ! command -v wails &> /dev/null; then
|
||||||
|
if [ -f "$HOME/go/bin/wails" ]; then
|
||||||
|
WAILS_CMD="$HOME/go/bin/wails"
|
||||||
|
echo "[*] Found Wails CLI at $WAILS_CMD"
|
||||||
|
else
|
||||||
|
echo "[-] Wails CLI not found. Please install Wails first."
|
||||||
|
echo " Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Building macOS Apple Silicon (arm64)..."
|
||||||
|
"$WAILS_CMD" build -platform darwin/arm64
|
||||||
|
|
||||||
|
echo "[*] Building macOS Intel (amd64)..."
|
||||||
|
"$WAILS_CMD" build -platform darwin/amd64
|
||||||
|
|
||||||
|
echo "[+] macOS arm64 and amd64 builds completed successfully!"
|
||||||
BIN
client/build/.DS_Store
vendored
Normal file
BIN
client/build/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -64,5 +64,13 @@
|
|||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền vị trí để xác thực mạng Wi-Fi phòng thi.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập camera để giám sát thi và xác thực khuôn mặt sinh viên.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập microphone để giám sát âm thanh phòng thi.</string>
|
||||||
|
<key>NSScreenCaptureUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền chia sẻ màn hình để giám sát và ghi lại quá trình thi.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -59,5 +59,14 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</array>
|
</array>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền vị trí để xác thực mạng Wi-Fi phòng thi.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập camera để giám sát thi và xác thực khuôn mặt sinh viên.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập microphone để giám sát âm thanh phòng thi.</string>
|
||||||
|
<key>NSScreenCaptureUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền chia sẻ màn hình để giám sát và ghi lại quá trình thi.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
||||||
|
|||||||
0
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal file → Executable file
0
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal file → Executable file
0
client/frontend/wailsjs/go/main/App.js
Normal file → Executable file
0
client/frontend/wailsjs/go/main/App.js
Normal file → Executable file
@@ -2,74 +2,11 @@ package blocker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
|
||||||
procEnumWindows = user32.NewProc("EnumWindows")
|
|
||||||
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
|
||||||
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
|
||||||
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
|
|
||||||
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
|
||||||
procGetWindow = user32.NewProc("GetWindow")
|
|
||||||
procGetWindowLongW = user32.NewProc("GetWindowLongW")
|
|
||||||
procGetAncestor = user32.NewProc("GetAncestor")
|
|
||||||
|
|
||||||
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
|
|
||||||
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
|
||||||
|
|
||||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
GW_OWNER = 4
|
|
||||||
WS_EX_TOOLWINDOW = 0x00000080
|
|
||||||
DWMWA_CLOAKED = 14
|
|
||||||
)
|
|
||||||
|
|
||||||
func isRealGUIWindow(hwnd uintptr) bool {
|
|
||||||
// 1. Phải đang hiển thị
|
|
||||||
ret, _, _ := procIsWindowVisible.Call(hwnd)
|
|
||||||
if ret == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level)
|
|
||||||
owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER)
|
|
||||||
if owner != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Không phải là tool window (WS_EX_TOOLWINDOW)
|
|
||||||
gwlExStyle := int32(-20) // GWL_EXSTYLE = -20
|
|
||||||
style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle))
|
|
||||||
if (style & WS_EX_TOOLWINDOW) != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo)
|
|
||||||
var cloaked uint32
|
|
||||||
hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4)
|
|
||||||
if hr == 0 && cloaked != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowInfo struct {
|
|
||||||
PID uint32
|
|
||||||
Title string
|
|
||||||
ProcessName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Blocker struct {
|
type Blocker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
allowedKeywords []string
|
allowedKeywords []string
|
||||||
@@ -84,121 +21,6 @@ var Instance = &Blocker{
|
|||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProcessMap() (map[uint32]string, map[uint32]uint32, error) {
|
|
||||||
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(snapshot)
|
|
||||||
|
|
||||||
var pe syscall.ProcessEntry32
|
|
||||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
|
||||||
|
|
||||||
err = syscall.Process32First(snapshot, &pe)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pm := make(map[uint32]string)
|
|
||||||
parents := make(map[uint32]uint32)
|
|
||||||
for {
|
|
||||||
name := syscall.UTF16ToString(pe.ExeFile[:])
|
|
||||||
pm[pe.ProcessID] = name
|
|
||||||
parents[pe.ProcessID] = pe.ParentProcessID
|
|
||||||
|
|
||||||
err = syscall.Process32Next(snapshot, &pe)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pm, parents, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getWindowText(hwnd uintptr) string {
|
|
||||||
length, _, _ := procGetWindowTextLengthW.Call(hwnd)
|
|
||||||
if length == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
buf := make([]uint16, length+1)
|
|
||||||
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1)
|
|
||||||
return syscall.UTF16ToString(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
enumWindowsMutex sync.Mutex
|
|
||||||
enumWindowsList []WindowInfo
|
|
||||||
enumProcessMap map[uint32]string
|
|
||||||
enumParentMap map[uint32]uint32
|
|
||||||
)
|
|
||||||
|
|
||||||
var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[BLOCKER] Callback panic recovered: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if !isRealGUIWindow(hwnd) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kiểm tra xem chủ sở hữu gốc (root owner) của cửa sổ này có thuộc về app ta hay không
|
|
||||||
rootHwnd, _, _ := procGetAncestor.Call(hwnd, 3) // GA_ROOTOWNER = 3
|
|
||||||
if rootHwnd != 0 {
|
|
||||||
var rootPid uint32
|
|
||||||
procGetWindowThreadProcessId.Call(rootHwnd, uintptr(unsafe.Pointer(&rootPid)))
|
|
||||||
if rootPid == uint32(os.Getpid()) {
|
|
||||||
return 1 // Cửa sổ thuộc về WebView2 / app của ta, bỏ qua không quét
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title := getWindowText(hwnd)
|
|
||||||
if title == "" {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var pid uint32
|
|
||||||
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
|
||||||
|
|
||||||
procName := enumProcessMap[pid]
|
|
||||||
if procName == "" {
|
|
||||||
procName = "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
enumWindowsList = append(enumWindowsList, WindowInfo{
|
|
||||||
PID: pid,
|
|
||||||
Title: title,
|
|
||||||
ProcessName: procName,
|
|
||||||
})
|
|
||||||
|
|
||||||
return 1
|
|
||||||
})
|
|
||||||
|
|
||||||
func EnumerateGUIWindows() ([]WindowInfo, map[uint32]uint32, error) {
|
|
||||||
pMap, parentMap, err := getProcessMap()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
enumWindowsMutex.Lock()
|
|
||||||
defer enumWindowsMutex.Unlock()
|
|
||||||
|
|
||||||
enumWindowsList = make([]WindowInfo, 0, 100)
|
|
||||||
enumProcessMap = pMap
|
|
||||||
enumParentMap = parentMap
|
|
||||||
|
|
||||||
procEnumWindows.Call(enumWindowsCallback, 0)
|
|
||||||
|
|
||||||
// Clean up map reference so GC can reclaim it
|
|
||||||
enumProcessMap = nil
|
|
||||||
enumParentMap = nil
|
|
||||||
|
|
||||||
// Copy to a new slice to return safely
|
|
||||||
res := make([]WindowInfo, len(enumWindowsList))
|
|
||||||
copy(res, enumWindowsList)
|
|
||||||
return res, parentMap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKeywordList(keywords string) []string {
|
func parseKeywordList(keywords string) []string {
|
||||||
keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
|
keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
|
||||||
parts := strings.FieldsFunc(keywords, func(r rune) bool {
|
parts := strings.FieldsFunc(keywords, func(r rune) bool {
|
||||||
@@ -253,113 +75,9 @@ func matchesAllowedKeyword(kw, pNameLower, wTitleLower string) bool {
|
|||||||
|
|
||||||
func (b *Blocker) SetKeywords(keywords string) {
|
func (b *Blocker) SetKeywords(keywords string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.allowedKeywords = parseKeywordList(keywords)
|
b.allowedKeywords = parseKeywordList(keywords)
|
||||||
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemAllowed = map[string]bool{
|
|
||||||
"explorer.exe": true,
|
|
||||||
"taskmgr.exe": true,
|
|
||||||
"cmd.exe": true,
|
|
||||||
"powershell.exe": true,
|
|
||||||
"conhost.exe": true,
|
|
||||||
"client.exe": true, // App Wails của ta
|
|
||||||
"simple_care_v1.0.exe": true,
|
|
||||||
"wails.exe": true,
|
|
||||||
"msedgewebview2.exe": true, // WebView2 runtime của Wails
|
|
||||||
"code.exe": true, // VSCode
|
|
||||||
"cursor.exe": true, // Cursor
|
|
||||||
"windsurf.exe": true, // Windsurf
|
|
||||||
"goland.exe": true, // GoLand
|
|
||||||
"goland64.exe": true, // GoLand
|
|
||||||
"idea64.exe": true, // IntelliJ IDEA
|
|
||||||
"clion64.exe": true, // CLion
|
|
||||||
"webstorm64.exe": true, // WebStorm
|
|
||||||
"pycharm64.exe": true, // PyCharm
|
|
||||||
"rider64.exe": true, // Rider
|
|
||||||
"studio64.exe": true, // Android Studio
|
|
||||||
"eclipse.exe": true, // Eclipse
|
|
||||||
"sublime_text.exe": true, // Sublime Text
|
|
||||||
"notepad++.exe": true, // Notepad++
|
|
||||||
"devenv.exe": true, // Visual Studio
|
|
||||||
"git-bash.exe": true, // Git Bash
|
|
||||||
"bash.exe": true, // Bash
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDescendant(pid, targetPid uint32, parentMap map[uint32]uint32) bool {
|
|
||||||
curr := pid
|
|
||||||
for i := 0; i < 16; i++ {
|
|
||||||
parent, ok := parentMap[curr]
|
|
||||||
if !ok || parent == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if parent == targetPid {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
curr = parent
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Blocker) checkAndKill() {
|
|
||||||
b.mu.Lock()
|
|
||||||
keywords := make([]string, len(b.allowedKeywords))
|
|
||||||
copy(keywords, b.allowedKeywords)
|
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
|
||||||
// Nếu không cấu hình keyword thì không chặn gì cả
|
|
||||||
if len(keywords) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentExec := ""
|
|
||||||
if execPath, err := os.Executable(); err == nil {
|
|
||||||
currentExec = strings.ToLower(filepath.Base(execPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
windows, parentMap, err := EnumerateGUIWindows()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
myPid := uint32(os.Getpid())
|
|
||||||
for _, w := range windows {
|
|
||||||
pNameLower := strings.ToLower(w.ProcessName)
|
|
||||||
wTitleLower := strings.ToLower(w.Title)
|
|
||||||
|
|
||||||
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này (bao gồm tiến trình con/cháu, và đổi tên)
|
|
||||||
isOurApp := w.PID == myPid || isDescendant(w.PID, myPid, parentMap)
|
|
||||||
if isOurApp || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
|
||||||
allowed := false
|
|
||||||
for _, kw := range keywords {
|
|
||||||
if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Nếu không nằm trong whitelist, tắt ứng dụng
|
|
||||||
if !allowed {
|
|
||||||
if b.OnBlocked != nil {
|
|
||||||
b.OnBlocked(w.ProcessName, w.Title)
|
|
||||||
}
|
|
||||||
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title)
|
|
||||||
h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID)
|
|
||||||
if err == nil {
|
|
||||||
errTerm := syscall.TerminateProcess(h, 0)
|
|
||||||
_ = syscall.CloseHandle(h)
|
|
||||||
if errTerm == nil && b.OnKill != nil {
|
|
||||||
b.OnKill(w.ProcessName, w.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Blocker) Start() {
|
func (b *Blocker) Start() {
|
||||||
|
|||||||
156
client/internal/blocker/blocker_darwin.go
Normal file
156
client/internal/blocker/blocker_darwin.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemAllowed = map[string]bool{
|
||||||
|
"finder": true,
|
||||||
|
"terminal": true,
|
||||||
|
"iterm": true,
|
||||||
|
"iterm2": true,
|
||||||
|
"bash": true,
|
||||||
|
"zsh": true,
|
||||||
|
"sh": true,
|
||||||
|
"wails": true,
|
||||||
|
"code": true,
|
||||||
|
"cursor": true,
|
||||||
|
"windsurf": true,
|
||||||
|
"goland": true,
|
||||||
|
"idea": true,
|
||||||
|
"clion": true,
|
||||||
|
"webstorm": true,
|
||||||
|
"pycharm": true,
|
||||||
|
"rider": true,
|
||||||
|
"studio": true,
|
||||||
|
"eclipse": true,
|
||||||
|
"sublime_text": true,
|
||||||
|
"git": true,
|
||||||
|
"docker": true,
|
||||||
|
"system events": true,
|
||||||
|
"osascript": true,
|
||||||
|
"client": true,
|
||||||
|
"simple_care_v1.0": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessInfo struct {
|
||||||
|
Name string
|
||||||
|
BundleID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleProcesses() (map[uint32]ProcessInfo, error) {
|
||||||
|
script := `tell application "System Events"
|
||||||
|
set out to ""
|
||||||
|
set procList to every process whose visible is true
|
||||||
|
repeat with p in procList
|
||||||
|
try
|
||||||
|
set nameStr to name of p
|
||||||
|
set pidVal to unix id of p
|
||||||
|
set bid to bundle identifier of p
|
||||||
|
if bid is missing value then
|
||||||
|
set bid to ""
|
||||||
|
end if
|
||||||
|
set out to out & nameStr & "|" & pidVal & "|" & bid & "\n"
|
||||||
|
on error
|
||||||
|
-- ignore
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
return out
|
||||||
|
end tell`
|
||||||
|
cmd := exec.Command("osascript", "-e", script)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procs := make(map[uint32]ProcessInfo)
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pName := parts[0]
|
||||||
|
pIdStr := parts[1]
|
||||||
|
bundleID := ""
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
bundleID = parts[2]
|
||||||
|
}
|
||||||
|
var pid uint32
|
||||||
|
if _, err := fmt.Sscanf(pIdStr, "%d", &pid); err == nil {
|
||||||
|
procs[pid] = ProcessInfo{
|
||||||
|
Name: pName,
|
||||||
|
BundleID: bundleID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return procs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {
|
||||||
|
b.mu.Lock()
|
||||||
|
keywords := make([]string, len(b.allowedKeywords))
|
||||||
|
copy(keywords, b.allowedKeywords)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentExec := ""
|
||||||
|
if execPath, err := os.Executable(); err == nil {
|
||||||
|
currentExec = strings.ToLower(filepath.Base(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
procs, err := getVisibleProcesses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[BLOCKER] Failed to get visible processes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myPid := uint32(os.Getpid())
|
||||||
|
for pid, info := range procs {
|
||||||
|
pNameLower := strings.ToLower(info.Name)
|
||||||
|
bundleIDLower := strings.ToLower(info.BundleID)
|
||||||
|
|
||||||
|
// 1. Always allow our app, system/critical developer tools, or agent helpers (and Antigravity IDE)
|
||||||
|
isAntigravity := strings.Contains(pNameLower, "antigravity") || strings.Contains(bundleIDLower, "antigravity")
|
||||||
|
if pid == myPid || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || isAntigravity {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if the process name contains any allowed keywords
|
||||||
|
allowed := false
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if matchesAllowedKeyword(kw, pNameLower, pNameLower) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If not allowed, kill the application
|
||||||
|
if !allowed {
|
||||||
|
if b.OnBlocked != nil {
|
||||||
|
b.OnBlocked(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", info.Name, pid)
|
||||||
|
proc, err := os.FindProcess(int(pid))
|
||||||
|
if err == nil {
|
||||||
|
errKill := proc.Kill()
|
||||||
|
if errKill == nil && b.OnKill != nil {
|
||||||
|
b.OnKill(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
client/internal/blocker/blocker_other.go
Normal file
5
client/internal/blocker/blocker_other.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {}
|
||||||
290
client/internal/blocker/blocker_windows.go
Normal file
290
client/internal/blocker/blocker_windows.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
procEnumWindows = user32.NewProc("EnumWindows")
|
||||||
|
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
||||||
|
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
||||||
|
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
|
||||||
|
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
||||||
|
procGetWindow = user32.NewProc("GetWindow")
|
||||||
|
procGetWindowLongW = user32.NewProc("GetWindowLongW")
|
||||||
|
procGetAncestor = user32.NewProc("GetAncestor")
|
||||||
|
|
||||||
|
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
|
||||||
|
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
||||||
|
|
||||||
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GW_OWNER = 4
|
||||||
|
WS_EX_TOOLWINDOW = 0x00000080
|
||||||
|
DWMWA_CLOAKED = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
func isRealGUIWindow(hwnd uintptr) bool {
|
||||||
|
// 1. Phải đang hiển thị
|
||||||
|
ret, _, _ := procIsWindowVisible.Call(hwnd)
|
||||||
|
if ret == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level)
|
||||||
|
owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER)
|
||||||
|
if owner != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Không phải là tool window (WS_EX_TOOLWINDOW)
|
||||||
|
gwlExStyle := int32(-20) // GWL_EXSTYLE = -20
|
||||||
|
style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle))
|
||||||
|
if (style & WS_EX_TOOLWINDOW) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo)
|
||||||
|
var cloaked uint32
|
||||||
|
hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4)
|
||||||
|
if hr == 0 && cloaked != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowInfo struct {
|
||||||
|
PID uint32
|
||||||
|
Title string
|
||||||
|
ProcessName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcessMap() (map[uint32]string, map[uint32]uint32, error) {
|
||||||
|
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer syscall.CloseHandle(snapshot)
|
||||||
|
|
||||||
|
var pe syscall.ProcessEntry32
|
||||||
|
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||||
|
|
||||||
|
err = syscall.Process32First(snapshot, &pe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pm := make(map[uint32]string)
|
||||||
|
parents := make(map[uint32]uint32)
|
||||||
|
for {
|
||||||
|
name := syscall.UTF16ToString(pe.ExeFile[:])
|
||||||
|
pm[pe.ProcessID] = name
|
||||||
|
parents[pe.ProcessID] = pe.ParentProcessID
|
||||||
|
|
||||||
|
err = syscall.Process32Next(snapshot, &pe)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pm, parents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWindowText(hwnd uintptr) string {
|
||||||
|
length, _, _ := procGetWindowTextLengthW.Call(hwnd)
|
||||||
|
if length == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
buf := make([]uint16, length+1)
|
||||||
|
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1)
|
||||||
|
return syscall.UTF16ToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
enumWindowsMutex sync.Mutex
|
||||||
|
enumWindowsList []WindowInfo
|
||||||
|
enumProcessMap map[uint32]string
|
||||||
|
enumParentMap map[uint32]uint32
|
||||||
|
)
|
||||||
|
|
||||||
|
var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[BLOCKER] Callback panic recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !isRealGUIWindow(hwnd) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra xem chủ sở hữu gốc (root owner) của cửa sổ này có thuộc về app ta hay không
|
||||||
|
rootHwnd, _, _ := procGetAncestor.Call(hwnd, 3) // GA_ROOTOWNER = 3
|
||||||
|
if rootHwnd != 0 {
|
||||||
|
var rootPid uint32
|
||||||
|
procGetWindowThreadProcessId.Call(rootHwnd, uintptr(unsafe.Pointer(&rootPid)))
|
||||||
|
if rootPid == uint32(os.Getpid()) {
|
||||||
|
return 1 // Cửa sổ thuộc về WebView2 / app của ta, bỏ qua không quét
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := getWindowText(hwnd)
|
||||||
|
if title == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid uint32
|
||||||
|
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
||||||
|
|
||||||
|
procName := enumProcessMap[pid]
|
||||||
|
if procName == "" {
|
||||||
|
procName = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
enumWindowsList = append(enumWindowsList, WindowInfo{
|
||||||
|
PID: pid,
|
||||||
|
Title: title,
|
||||||
|
ProcessName: procName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
func EnumerateGUIWindows() ([]WindowInfo, map[uint32]uint32, error) {
|
||||||
|
pMap, parentMap, err := getProcessMap()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enumWindowsMutex.Lock()
|
||||||
|
defer enumWindowsMutex.Unlock()
|
||||||
|
|
||||||
|
enumWindowsList = make([]WindowInfo, 0, 100)
|
||||||
|
enumProcessMap = pMap
|
||||||
|
enumParentMap = parentMap
|
||||||
|
|
||||||
|
procEnumWindows.Call(enumWindowsCallback, 0)
|
||||||
|
|
||||||
|
// Clean up map reference so GC can reclaim it
|
||||||
|
enumProcessMap = nil
|
||||||
|
enumParentMap = nil
|
||||||
|
|
||||||
|
// Copy to a new slice to return safely
|
||||||
|
res := make([]WindowInfo, len(enumWindowsList))
|
||||||
|
copy(res, enumWindowsList)
|
||||||
|
return res, parentMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemAllowed = map[string]bool{
|
||||||
|
"explorer.exe": true,
|
||||||
|
"taskmgr.exe": true,
|
||||||
|
"cmd.exe": true,
|
||||||
|
"powershell.exe": true,
|
||||||
|
"conhost.exe": true,
|
||||||
|
"client.exe": true, // App Wails của ta
|
||||||
|
"simple_care_v1.0.exe": true,
|
||||||
|
"wails.exe": true,
|
||||||
|
"msedgewebview2.exe": true, // WebView2 runtime của Wails
|
||||||
|
"code.exe": true, // VSCode
|
||||||
|
"cursor.exe": true, // Cursor
|
||||||
|
"windsurf.exe": true, // Windsurf
|
||||||
|
"goland.exe": true, // GoLand
|
||||||
|
"goland64.exe": true, // GoLand
|
||||||
|
"idea64.exe": true, // IntelliJ IDEA
|
||||||
|
"clion64.exe": true, // CLion
|
||||||
|
"webstorm64.exe": true, // WebStorm
|
||||||
|
"pycharm64.exe": true, // PyCharm
|
||||||
|
"rider64.exe": true, // Rider
|
||||||
|
"studio64.exe": true, // Android Studio
|
||||||
|
"eclipse.exe": true, // Eclipse
|
||||||
|
"sublime_text.exe": true, // Sublime Text
|
||||||
|
"notepad++.exe": true, // Notepad++
|
||||||
|
"devenv.exe": true, // Visual Studio
|
||||||
|
"git-bash.exe": true, // Git Bash
|
||||||
|
"bash.exe": true, // Bash
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDescendant(pid, targetPid uint32, parentMap map[uint32]uint32) bool {
|
||||||
|
curr := pid
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
parent, ok := parentMap[curr]
|
||||||
|
if !ok || parent == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parent == targetPid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
curr = parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {
|
||||||
|
b.mu.Lock()
|
||||||
|
keywords := make([]string, len(b.allowedKeywords))
|
||||||
|
copy(keywords, b.allowedKeywords)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
// Nếu không cấu hình keyword thì không chặn gì cả
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentExec := ""
|
||||||
|
if execPath, err := os.Executable(); err == nil {
|
||||||
|
currentExec = strings.ToLower(filepath.Base(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
windows, parentMap, err := EnumerateGUIWindows()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myPid := uint32(os.Getpid())
|
||||||
|
for _, w := range windows {
|
||||||
|
pNameLower := strings.ToLower(w.ProcessName)
|
||||||
|
wTitleLower := strings.ToLower(w.Title)
|
||||||
|
|
||||||
|
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này (bao gồm tiến trình con/cháu, và đổi tên)
|
||||||
|
isOurApp := w.PID == myPid || isDescendant(w.PID, myPid, parentMap)
|
||||||
|
if isOurApp || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
||||||
|
allowed := false
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nếu không nằm trong whitelist, tắt ứng dụng
|
||||||
|
if !allowed {
|
||||||
|
if b.OnBlocked != nil {
|
||||||
|
b.OnBlocked(w.ProcessName, w.Title)
|
||||||
|
}
|
||||||
|
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title)
|
||||||
|
h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID)
|
||||||
|
if err == nil {
|
||||||
|
errTerm := syscall.TerminateProcess(h, 0)
|
||||||
|
_ = syscall.CloseHandle(h)
|
||||||
|
if errTerm == nil && b.OnKill != nil {
|
||||||
|
b.OnKill(w.ProcessName, w.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
client/internal/camera/camera_darwin.go
Normal file
46
client/internal/camera/camera_darwin.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package camera
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreImage -framework CoreMedia -framework CoreVideo -framework AppKit
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
int StartNativeCamera(void);
|
||||||
|
void StopNativeCamera(void);
|
||||||
|
char* GetLatestCameraFrame(void);
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartCapture starts native camera capture via AVCaptureSession
|
||||||
|
func StartCapture() error {
|
||||||
|
ret := C.StartNativeCamera()
|
||||||
|
if ret != 0 {
|
||||||
|
log.Println("[CAMERA] Failed to start native camera capture")
|
||||||
|
return fmt.Errorf("failed to start camera (code %d)", int(ret))
|
||||||
|
}
|
||||||
|
log.Println("[CAMERA] Native camera capture started successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCapture stops native camera capture
|
||||||
|
func StopCapture() {
|
||||||
|
C.StopNativeCamera()
|
||||||
|
log.Println("[CAMERA] Native camera capture stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame returns the latest camera frame as a data:image/jpeg;base64,... string
|
||||||
|
// Returns empty string if no frame is available
|
||||||
|
func GetFrame() string {
|
||||||
|
cstr := C.GetLatestCameraFrame()
|
||||||
|
if cstr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer C.free(unsafe.Pointer(cstr))
|
||||||
|
return C.GoString(cstr)
|
||||||
|
}
|
||||||
217
client/internal/camera/camera_darwin.m
Normal file
217
client/internal/camera/camera_darwin.m
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ── Shared state ──────────────────────────────────────────────
|
||||||
|
static AVCaptureSession *captureSession = nil;
|
||||||
|
static AVCaptureVideoDataOutput *videoOutput = nil;
|
||||||
|
static dispatch_queue_t captureQueue = nil;
|
||||||
|
static CIContext *sharedCIContext = nil;
|
||||||
|
|
||||||
|
// Latest frame stored as JPEG base64 (owning reference)
|
||||||
|
static NSString *latestFrameBase64 = nil;
|
||||||
|
static NSLock *frameLock = nil;
|
||||||
|
static int frameCount = 0;
|
||||||
|
|
||||||
|
// ── Delegate that receives sample buffers ─────────────────────
|
||||||
|
@interface CameraFrameDelegate : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation CameraFrameDelegate
|
||||||
|
|
||||||
|
- (void)captureOutput:(AVCaptureOutput *)output
|
||||||
|
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
||||||
|
fromConnection:(AVCaptureConnection *)connection {
|
||||||
|
@autoreleasepool {
|
||||||
|
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
|
||||||
|
if (!imageBuffer) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: imageBuffer is NULL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer];
|
||||||
|
if (!sharedCIContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGImageRef cgImage = [sharedCIContext createCGImage:ciImage fromRect:ciImage.extent];
|
||||||
|
if (!cgImage) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: cgImage is NULL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to JPEG NSData (quality ≈ 0.4)
|
||||||
|
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage];
|
||||||
|
CGImageRelease(cgImage);
|
||||||
|
if (!rep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *props = @{NSImageCompressionFactor: @(0.4)};
|
||||||
|
NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props];
|
||||||
|
if (!jpegData) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: jpegData is NULL");
|
||||||
|
[rep release];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *b64 = [jpegData base64EncodedStringWithOptions:0];
|
||||||
|
// Create a retained string (ownership transfer)
|
||||||
|
NSString *dataUrl = [[NSString alloc] initWithFormat:@"data:image/jpeg;base64,%@", b64];
|
||||||
|
[rep release];
|
||||||
|
|
||||||
|
[frameLock lock];
|
||||||
|
if (latestFrameBase64) {
|
||||||
|
[latestFrameBase64 release];
|
||||||
|
}
|
||||||
|
latestFrameBase64 = dataUrl; // retained copy
|
||||||
|
frameCount++;
|
||||||
|
int fc = frameCount;
|
||||||
|
[frameLock unlock];
|
||||||
|
|
||||||
|
// Log first few frames to confirm camera is working
|
||||||
|
if (fc <= 3) {
|
||||||
|
NSLog(@"[CAMERA] Frame #%d captured, size=%lu bytes", fc, (unsigned long)jpegData.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
static CameraFrameDelegate *frameDelegate = nil;
|
||||||
|
|
||||||
|
// ── C-exported functions ──────────────────────────────────────
|
||||||
|
|
||||||
|
// StartNativeCamera: returns 0 on success, -1 on failure
|
||||||
|
int StartNativeCamera(void) {
|
||||||
|
NSLog(@"[CAMERA] StartNativeCamera called");
|
||||||
|
|
||||||
|
if (captureSession && captureSession.isRunning) {
|
||||||
|
NSLog(@"[CAMERA] Session already running");
|
||||||
|
return 0; // already running
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frameLock) {
|
||||||
|
frameLock = [[NSLock alloc] init];
|
||||||
|
}
|
||||||
|
frameCount = 0;
|
||||||
|
|
||||||
|
// Check camera permission
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
NSLog(@"[CAMERA] Camera permission status: %ld (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", (long)status);
|
||||||
|
|
||||||
|
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
|
||||||
|
NSLog(@"[CAMERA] Camera permission denied (status=%ld)", (long)status);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (status == AVAuthorizationStatusNotDetermined) {
|
||||||
|
NSLog(@"[CAMERA] Requesting camera permission...");
|
||||||
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||||
|
__block BOOL granted = NO;
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL g) {
|
||||||
|
granted = g;
|
||||||
|
NSLog(@"[CAMERA] Permission request result: %@", g ? @"GRANTED" : @"DENIED");
|
||||||
|
dispatch_semaphore_signal(sem);
|
||||||
|
}];
|
||||||
|
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
|
||||||
|
if (!granted) {
|
||||||
|
NSLog(@"[CAMERA] Camera permission not granted after request");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sharedCIContext) {
|
||||||
|
sharedCIContext = [[CIContext contextWithOptions:nil] retain];
|
||||||
|
}
|
||||||
|
|
||||||
|
captureSession = [[AVCaptureSession alloc] init];
|
||||||
|
captureSession.sessionPreset = AVCaptureSessionPresetLow; // 320×240-ish
|
||||||
|
NSLog(@"[CAMERA] Session created with preset Low");
|
||||||
|
|
||||||
|
// Find default video device
|
||||||
|
AVCaptureDevice *camera = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
|
||||||
|
if (!camera) {
|
||||||
|
NSLog(@"[CAMERA] No camera device found");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
NSLog(@"[CAMERA] Found camera: %@", camera.localizedName);
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:camera error:&error];
|
||||||
|
if (error || !input) {
|
||||||
|
NSLog(@"[CAMERA] Cannot create camera input: %@", error);
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([captureSession canAddInput:input]) {
|
||||||
|
[captureSession addInput:input];
|
||||||
|
NSLog(@"[CAMERA] Input added to session");
|
||||||
|
} else {
|
||||||
|
NSLog(@"[CAMERA] Cannot add camera input to session");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video data output
|
||||||
|
videoOutput = [[AVCaptureVideoDataOutput alloc] init];
|
||||||
|
videoOutput.videoSettings = @{
|
||||||
|
(NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)
|
||||||
|
};
|
||||||
|
videoOutput.alwaysDiscardsLateVideoFrames = YES;
|
||||||
|
|
||||||
|
captureQueue = dispatch_queue_create("com.simplecare.camera", DISPATCH_QUEUE_SERIAL);
|
||||||
|
frameDelegate = [[CameraFrameDelegate alloc] init];
|
||||||
|
[videoOutput setSampleBufferDelegate:frameDelegate queue:captureQueue];
|
||||||
|
|
||||||
|
if ([captureSession canAddOutput:videoOutput]) {
|
||||||
|
[captureSession addOutput:videoOutput];
|
||||||
|
NSLog(@"[CAMERA] Output added to session");
|
||||||
|
} else {
|
||||||
|
NSLog(@"[CAMERA] Cannot add video output to session");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[captureSession startRunning];
|
||||||
|
NSLog(@"[CAMERA] Session startRunning called, isRunning=%d", captureSession.isRunning);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopNativeCamera(void) {
|
||||||
|
NSLog(@"[CAMERA] StopNativeCamera called");
|
||||||
|
if (captureSession && captureSession.isRunning) {
|
||||||
|
[captureSession stopRunning];
|
||||||
|
NSLog(@"[CAMERA] Session stopped");
|
||||||
|
}
|
||||||
|
captureSession = nil;
|
||||||
|
videoOutput = nil;
|
||||||
|
frameDelegate = nil;
|
||||||
|
|
||||||
|
[frameLock lock];
|
||||||
|
if (latestFrameBase64) {
|
||||||
|
[latestFrameBase64 release];
|
||||||
|
latestFrameBase64 = nil;
|
||||||
|
}
|
||||||
|
[frameLock unlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestCameraFrame: returns a C-string (caller must free) or NULL
|
||||||
|
char* GetLatestCameraFrame(void) {
|
||||||
|
[frameLock lock];
|
||||||
|
NSString *frame = latestFrameBase64;
|
||||||
|
latestFrameBase64 = nil; // transfers ownership to caller
|
||||||
|
[frameLock unlock];
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char *res = strdup([frame UTF8String]);
|
||||||
|
[frame release]; // Release since we had ownership
|
||||||
|
return res;
|
||||||
|
}
|
||||||
20
client/internal/camera/camera_other.go
Normal file
20
client/internal/camera/camera_other.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package camera
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
// StartCapture is a no-op on non-darwin platforms (Windows uses different webcam API)
|
||||||
|
func StartCapture() error {
|
||||||
|
log.Println("[CAMERA] Native camera capture not supported on this platform")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCapture is a no-op on non-darwin platforms
|
||||||
|
func StopCapture() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame always returns empty on non-darwin platforms
|
||||||
|
func GetFrame() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
89
client/internal/guard/guard_darwin.go
Normal file
89
client/internal/guard/guard_darwin.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kbinani/screenshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
guardOnce sync.Once
|
||||||
|
guardViolation func(string)
|
||||||
|
guardStop chan struct{}
|
||||||
|
suppressMu sync.Mutex
|
||||||
|
suppressViolationsUntil time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
|
||||||
|
func SuppressFor(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressMu.Lock()
|
||||||
|
next := time.Now().Add(d)
|
||||||
|
if next.After(suppressViolationsUntil) {
|
||||||
|
suppressViolationsUntil = next
|
||||||
|
}
|
||||||
|
suppressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func violationsSuppressed() bool {
|
||||||
|
suppressMu.Lock()
|
||||||
|
defer suppressMu.Unlock()
|
||||||
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình).
|
||||||
|
func Start(onViolation func(reason string)) {
|
||||||
|
guardOnce.Do(func() {
|
||||||
|
if onViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardViolation = onViolation
|
||||||
|
guardStop = make(chan struct{})
|
||||||
|
|
||||||
|
if screenshot.NumActiveDisplays() > 1 {
|
||||||
|
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go pollLoop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
if guardStop != nil {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(guardStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollLoop() {
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkEnvironment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEnvironment() {
|
||||||
|
if guardViolation == nil || violationsSuppressed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := screenshot.NumActiveDisplays(); n > 1 {
|
||||||
|
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
package guard
|
package guard
|
||||||
|
|
||||||
|
|||||||
107
client/internal/screen/screen_darwin.go
Normal file
107
client/internal/screen/screen_darwin.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package screen
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c -Wno-deprecated-declarations -Wno-unguarded-availability-new
|
||||||
|
#cgo LDFLAGS: -framework CoreGraphics -framework Foundation -framework AppKit
|
||||||
|
|
||||||
|
// Disable availability checks - we handle this at runtime
|
||||||
|
#define __API_UNAVAILABLE(...)
|
||||||
|
#define API_UNAVAILABLE(...)
|
||||||
|
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
// Use dlsym to call CGWindowListCreateImage dynamically to bypass macOS 15 availability check
|
||||||
|
typedef CGImageRef (*CGWindowListCreateImageFunc)(CGRect, CGWindowListOption, CGWindowID, CGWindowImageOption);
|
||||||
|
|
||||||
|
static unsigned char* CaptureFullDesktop(int* outLen, int quality) {
|
||||||
|
// Dynamically load CGWindowListCreateImage to bypass compile-time availability check
|
||||||
|
static CGWindowListCreateImageFunc createImageFunc = NULL;
|
||||||
|
if (!createImageFunc) {
|
||||||
|
void *handle = dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_LAZY);
|
||||||
|
if (handle) {
|
||||||
|
createImageFunc = (CGWindowListCreateImageFunc)dlsym(handle, "CGWindowListCreateImage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createImageFunc) {
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
CGImageRef image = createImageFunc(
|
||||||
|
CGRectInfinite,
|
||||||
|
kCGWindowListOptionOnScreenOnly,
|
||||||
|
kCGNullWindowID,
|
||||||
|
kCGWindowImageDefault
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autoreleasepool {
|
||||||
|
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:image];
|
||||||
|
CGImageRelease(image);
|
||||||
|
|
||||||
|
if (!rep) {
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
float q = (float)quality / 100.0f;
|
||||||
|
if (q < 0.1f) q = 0.1f;
|
||||||
|
if (q > 1.0f) q = 1.0f;
|
||||||
|
|
||||||
|
NSDictionary *props = @{NSImageCompressionFactor: @(q)};
|
||||||
|
NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props];
|
||||||
|
|
||||||
|
if (!jpegData || jpegData.length == 0) {
|
||||||
|
[rep release];
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
*outLen = (int)jpegData.length;
|
||||||
|
unsigned char *buf = (unsigned char*)malloc(jpegData.length);
|
||||||
|
memcpy(buf, jpegData.bytes, jpegData.length);
|
||||||
|
[rep release];
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureScreen captures the full desktop on macOS using CGWindowListCreateImage (via dlsym)
|
||||||
|
// Returns a data:image/jpeg;base64,... string
|
||||||
|
func CaptureScreen() (string, error) {
|
||||||
|
var outLen C.int
|
||||||
|
quality := C.int(50) // JPEG quality 50%
|
||||||
|
|
||||||
|
buf := C.CaptureFullDesktop(&outLen, quality)
|
||||||
|
if buf == nil || int(outLen) == 0 {
|
||||||
|
return "", fmt.Errorf("failed to capture screen: CGWindowListCreateImage returned nil")
|
||||||
|
}
|
||||||
|
defer C.free(unsafe.Pointer(buf))
|
||||||
|
|
||||||
|
jpegBytes := C.GoBytes(unsafe.Pointer(buf), outLen)
|
||||||
|
|
||||||
|
if len(jpegBytes) < 100 {
|
||||||
|
log.Printf("[SCREEN] Warning: captured image is very small (%d bytes), may indicate permission issue", len(jpegBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(jpegBytes)
|
||||||
|
return "data:image/jpeg;base64," + encoded, nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
package screen
|
package screen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
|
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
|
||||||
|
// Non-darwin: dùng kbinani/screenshot
|
||||||
func CaptureScreen() (string, error) {
|
func CaptureScreen() (string, error) {
|
||||||
n := screenshot.NumActiveDisplays()
|
n := screenshot.NumActiveDisplays()
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
83
client/internal/winapi/wifi_darwin.go
Normal file
83
client/internal/winapi/wifi_darwin.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -framework CoreWLAN -framework CoreLocation -framework Foundation -framework AVFoundation -framework CoreGraphics
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char* ssid;
|
||||||
|
char* bssid;
|
||||||
|
} CWifiInfo;
|
||||||
|
|
||||||
|
void RequestLocationPermission();
|
||||||
|
void RequestCameraAndMicPermission();
|
||||||
|
void RequestScreenCapturePermission();
|
||||||
|
CWifiInfo GetCurrentWifiInfo();
|
||||||
|
int GetCameraPermissionStatus();
|
||||||
|
int GetMicrophonePermissionStatus();
|
||||||
|
int GetScreenCapturePermissionStatus();
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission on macOS
|
||||||
|
func RequestLocationAccess() {
|
||||||
|
C.RequestLocationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess requests Camera and Microphone permissions on macOS
|
||||||
|
func RequestCameraAndMicAccess() {
|
||||||
|
C.RequestCameraAndMicPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess requests Screen Capture permission on macOS
|
||||||
|
func RequestScreenCaptureAccess() {
|
||||||
|
C.RequestScreenCapturePermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCameraPermission status on macOS
|
||||||
|
func GetCameraPermission() int {
|
||||||
|
return int(C.GetCameraPermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMicrophonePermission status on macOS
|
||||||
|
func GetMicrophonePermission() int {
|
||||||
|
return int(C.GetMicrophonePermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScreenCapturePermission status on macOS
|
||||||
|
func GetScreenCapturePermission() int {
|
||||||
|
return int(C.GetScreenCapturePermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWifiConnection đọc SSID và BSSID từ CoreWLAN (macOS)
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
info := C.GetCurrentWifiInfo()
|
||||||
|
defer func() {
|
||||||
|
if info.ssid != nil {
|
||||||
|
C.free(unsafe.Pointer(info.ssid))
|
||||||
|
}
|
||||||
|
if info.bssid != nil {
|
||||||
|
C.free(unsafe.Pointer(info.bssid))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssid := ""
|
||||||
|
if info.ssid != nil {
|
||||||
|
ssid = C.GoString(info.ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
bssid := ""
|
||||||
|
if info.bssid != nil {
|
||||||
|
bssid = C.GoString(info.bssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WifiConnection{
|
||||||
|
SSID: ssid,
|
||||||
|
BSSID: normalizeMAC(bssid),
|
||||||
|
}
|
||||||
|
}
|
||||||
101
client/internal/winapi/wifi_darwin.m
Normal file
101
client/internal/winapi/wifi_darwin.m
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#import <CoreWLAN/CoreWLAN.h>
|
||||||
|
#import <CoreLocation/CoreLocation.h>
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
static CLLocationManager *locationManager = nil;
|
||||||
|
|
||||||
|
void RequestLocationPermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (locationManager == nil) {
|
||||||
|
locationManager = [[CLLocationManager alloc] init];
|
||||||
|
}
|
||||||
|
if (@available(macOS 10.15, *)) {
|
||||||
|
[locationManager requestWhenInUseAuthorization];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RequestCameraAndMicPermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
// Request Camera
|
||||||
|
AVAuthorizationStatus cameraStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
if (cameraStatus == AVAuthorizationStatusNotDetermined) {
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
|
||||||
|
// Camera permission requested
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request Microphone
|
||||||
|
AVAuthorizationStatus micStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
||||||
|
if (micStatus == AVAuthorizationStatusNotDetermined) {
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
|
||||||
|
// Mic permission requested
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RequestScreenCapturePermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (@available(macOS 11.0, *)) {
|
||||||
|
BOOL hasAccess = CGPreflightScreenCaptureAccess();
|
||||||
|
if (!hasAccess) {
|
||||||
|
CGRequestScreenCaptureAccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetCameraPermissionStatus() {
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetMicrophonePermissionStatus() {
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetScreenCapturePermissionStatus() {
|
||||||
|
if (@available(macOS 11.0, *)) {
|
||||||
|
return CGPreflightScreenCaptureAccess() ? 1 : 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char* ssid;
|
||||||
|
char* bssid;
|
||||||
|
} CWifiInfo;
|
||||||
|
|
||||||
|
CWifiInfo GetCurrentWifiInfo() {
|
||||||
|
CWifiInfo info;
|
||||||
|
info.ssid = NULL;
|
||||||
|
info.bssid = NULL;
|
||||||
|
|
||||||
|
@autoreleasepool {
|
||||||
|
CWWiFiClient *client = [[CWWiFiClient alloc] init];
|
||||||
|
if (client != nil) {
|
||||||
|
CWInterface *interface = [client interface];
|
||||||
|
if (interface != nil) {
|
||||||
|
NSString *ssidStr = [interface ssid];
|
||||||
|
NSString *bssidStr = [interface bssid];
|
||||||
|
if (ssidStr != nil) {
|
||||||
|
info.ssid = strdup([ssidStr UTF8String]);
|
||||||
|
}
|
||||||
|
if (bssidStr != nil) {
|
||||||
|
info.bssid = strdup([bssidStr UTF8String]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
26
client/internal/winapi/wifi_other.go
Normal file
26
client/internal/winapi/wifi_other.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
// GetWifiConnection returns an empty WifiConnection stub for other systems
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
return WifiConnection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission (stub for other systems)
|
||||||
|
func RequestLocationAccess() {}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess stub
|
||||||
|
func RequestCameraAndMicAccess() {}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess stub
|
||||||
|
func RequestScreenCaptureAccess() {}
|
||||||
|
|
||||||
|
// GetCameraPermission stub
|
||||||
|
func GetCameraPermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetMicrophonePermission stub
|
||||||
|
func GetMicrophonePermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetScreenCapturePermission stub
|
||||||
|
func GetScreenCapturePermission() int { return -1 }
|
||||||
58
client/internal/winapi/wifi_windows.go
Normal file
58
client/internal/winapi/wifi_windows.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetWifiConnection đọc SSID và BSSID từ netsh (Windows)
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
cmd := exec.Command("netsh", "wlan", "show", "interfaces")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return WifiConnection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var conn WifiConnection
|
||||||
|
for _, line := range strings.Split(out.String(), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") {
|
||||||
|
if v := valueAfterColon(trimmed); v != "" {
|
||||||
|
conn.SSID = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác)
|
||||||
|
if strings.Contains(lower, "bssid") {
|
||||||
|
if v := valueAfterColon(trimmed); v != "" {
|
||||||
|
conn.BSSID = normalizeMAC(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission (stub for Windows)
|
||||||
|
func RequestLocationAccess() {}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess stub
|
||||||
|
func RequestCameraAndMicAccess() {}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess stub
|
||||||
|
func RequestScreenCaptureAccess() {}
|
||||||
|
|
||||||
|
// GetCameraPermission stub
|
||||||
|
func GetCameraPermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetMicrophonePermission stub
|
||||||
|
func GetMicrophonePermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetScreenCapturePermission stub
|
||||||
|
func GetScreenCapturePermission() int { return -1 }
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package winapi
|
package winapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối
|
// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối
|
||||||
@@ -18,36 +15,6 @@ func GetWifiSSID() string {
|
|||||||
return GetWifiConnection().SSID
|
return GetWifiConnection().SSID
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWifiConnection đọc SSID và BSSID từ netsh (Windows)
|
|
||||||
func GetWifiConnection() WifiConnection {
|
|
||||||
cmd := exec.Command("netsh", "wlan", "show", "interfaces")
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return WifiConnection{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var conn WifiConnection
|
|
||||||
for _, line := range strings.Split(out.String(), "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
lower := strings.ToLower(trimmed)
|
|
||||||
if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") {
|
|
||||||
if v := valueAfterColon(trimmed); v != "" {
|
|
||||||
conn.SSID = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác)
|
|
||||||
if strings.Contains(lower, "bssid") {
|
|
||||||
if v := valueAfterColon(trimmed); v != "" {
|
|
||||||
conn.BSSID = normalizeMAC(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAfterColon(line string) string {
|
func valueAfterColon(line string) string {
|
||||||
idx := strings.Index(line, ":")
|
idx := strings.Index(line, ":")
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
|
|||||||
30
client/internal/winapi/window_darwin.go
Normal file
30
client/internal/winapi/window_darwin.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivateAppWindow — đưa cửa sổ app lên trước
|
||||||
|
func ActivateAppWindow(titleHint string) {
|
||||||
|
pid := os.Getpid()
|
||||||
|
script := fmt.Sprintf(`tell application "System Events" to set frontmost of first process whose unix id is %d to true`, pid)
|
||||||
|
cmd := exec.Command("osascript", "-e", script)
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayNotifySound — beep hệ thống macOS
|
||||||
|
func PlayNotifySound() {
|
||||||
|
cmd := exec.Command("osascript", "-e", "beep")
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowWarningMessageBox hiển thị hộp thoại cảnh báo macOS bất đồng bộ
|
||||||
|
func ShowWarningMessageBox(title, message string) {
|
||||||
|
script := fmt.Sprintf(`display alert %q message %q`, title, message)
|
||||||
|
cmd := exec.Command("osascript", "-e", script)
|
||||||
|
_ = cmd.Start() // Run asynchronously
|
||||||
|
}
|
||||||
9
client/internal/winapi/window_other.go
Normal file
9
client/internal/winapi/window_other.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
func ActivateAppWindow(titleHint string) {}
|
||||||
|
|
||||||
|
func PlayNotifySound() {}
|
||||||
|
|
||||||
|
func ShowWarningMessageBox(title, message string) {}
|
||||||
Reference in New Issue
Block a user