diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b352f12 Binary files /dev/null and b/.DS_Store differ diff --git a/client/.DS_Store b/client/.DS_Store new file mode 100644 index 0000000..b7e1b5a Binary files /dev/null and b/client/.DS_Store differ diff --git a/client/README.md b/client/README.md index 397b08b..1ccc48e 100644 --- a/client/README.md +++ b/client/README.md @@ -16,4 +16,18 @@ to this in your browser, and you can call your Go code from devtools. ## 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 + ``` diff --git a/client/build.sh b/client/build.sh new file mode 100755 index 0000000..994cfb7 --- /dev/null +++ b/client/build.sh @@ -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!" diff --git a/client/build/.DS_Store b/client/build/.DS_Store new file mode 100644 index 0000000..1b66d3d Binary files /dev/null and b/client/build/.DS_Store differ diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts old mode 100644 new mode 100755 diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js old mode 100644 new mode 100755 diff --git a/client/internal/blocker/blocker.go b/client/internal/blocker/blocker.go index ed14ea5..5db05af 100644 --- a/client/internal/blocker/blocker.go +++ b/client/internal/blocker/blocker.go @@ -2,74 +2,11 @@ package blocker import ( "log" - "os" - "path/filepath" "strings" "sync" - "syscall" "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 { mu sync.Mutex allowedKeywords []string @@ -84,121 +21,6 @@ var Instance = &Blocker{ 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 { keywords = strings.ReplaceAll(keywords, "\r\n", "\n") 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) { b.mu.Lock() - defer b.mu.Unlock() - 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() - - // 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) - } - } - } - } + log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords) } func (b *Blocker) Start() { diff --git a/client/internal/blocker/blocker_darwin.go b/client/internal/blocker/blocker_darwin.go new file mode 100644 index 0000000..6d64af8 --- /dev/null +++ b/client/internal/blocker/blocker_darwin.go @@ -0,0 +1,133 @@ +//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, +} + +func getVisibleProcesses() (map[uint32]string, error) { + script := `tell application "System Events" + set nameList to name of every process whose visible is true + set pidList to unix id of every process whose visible is true + set out to "" + repeat with i from 1 to count of nameList + set out to out & item i of nameList & ":" & item i of pidList & "\n" + 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]string) + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.LastIndex(line, ":") + if idx == -1 { + continue + } + pName := line[:idx] + pIdStr := line[idx+1:] + var pid uint32 + if _, err := fmt.Sscanf(pIdStr, "%d", &pid); err == nil { + procs[pid] = pName + } + } + 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, name := range procs { + pNameLower := strings.ToLower(name) + + // 1. Always allow our app, system/critical developer tools, or agent helpers + if pid == myPid || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") { + 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(name, name) + } + log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", name, pid) + proc, err := os.FindProcess(int(pid)) + if err == nil { + errKill := proc.Kill() + if errKill == nil && b.OnKill != nil { + b.OnKill(name, name) + } + } + } + } +} diff --git a/client/internal/blocker/blocker_other.go b/client/internal/blocker/blocker_other.go new file mode 100644 index 0000000..8a28577 --- /dev/null +++ b/client/internal/blocker/blocker_other.go @@ -0,0 +1,5 @@ +//go:build !windows && !darwin + +package blocker + +func (b *Blocker) checkAndKill() {} diff --git a/client/internal/blocker/blocker_windows.go b/client/internal/blocker/blocker_windows.go new file mode 100644 index 0000000..7f98e9c --- /dev/null +++ b/client/internal/blocker/blocker_windows.go @@ -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) + } + } + } + } +} diff --git a/client/internal/guard/guard_darwin.go b/client/internal/guard/guard_darwin.go new file mode 100644 index 0000000..8e973c9 --- /dev/null +++ b/client/internal/guard/guard_darwin.go @@ -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 + } +} diff --git a/client/internal/guard/guard_other.go b/client/internal/guard/guard_other.go index fecc602..e5fce7b 100644 --- a/client/internal/guard/guard_other.go +++ b/client/internal/guard/guard_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !darwin package guard diff --git a/client/internal/winapi/wifi_darwin.go b/client/internal/winapi/wifi_darwin.go new file mode 100644 index 0000000..1f5a3af --- /dev/null +++ b/client/internal/winapi/wifi_darwin.go @@ -0,0 +1,46 @@ +//go:build darwin + +package winapi + +import ( + "bytes" + "os/exec" + "strings" +) + +// GetWifiConnection đọc SSID và BSSID từ ipconfig (macOS) +func GetWifiConnection() WifiConnection { + // Find Wi-Fi interface dynamically + wifiInterface := "en0" + cmdPort := exec.Command("networksetup", "-listallhardwareports") + if outPort, err := cmdPort.Output(); err == nil { + lines := strings.Split(string(outPort), "\n") + for i := 0; i < len(lines)-1; i++ { + if strings.Contains(lines[i], "Hardware Port: Wi-Fi") { + next := strings.TrimSpace(lines[i+1]) + if strings.HasPrefix(next, "Device:") { + wifiInterface = strings.TrimSpace(strings.TrimPrefix(next, "Device:")) + break + } + } + } + } + + cmd := exec.Command("ipconfig", "getsummary", wifiInterface) + 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) + if strings.HasPrefix(trimmed, "SSID :") { + conn.SSID = strings.TrimSpace(strings.TrimPrefix(trimmed, "SSID :")) + } else if strings.HasPrefix(trimmed, "BSSID :") { + conn.BSSID = normalizeMAC(strings.TrimSpace(strings.TrimPrefix(trimmed, "BSSID :"))) + } + } + return conn +} diff --git a/client/internal/winapi/wifi_other.go b/client/internal/winapi/wifi_other.go new file mode 100644 index 0000000..550643c --- /dev/null +++ b/client/internal/winapi/wifi_other.go @@ -0,0 +1,8 @@ +//go:build !windows && !darwin + +package winapi + +// GetWifiConnection returns an empty WifiConnection stub for other systems +func GetWifiConnection() WifiConnection { + return WifiConnection{} +} diff --git a/client/internal/winapi/wifi_windows.go b/client/internal/winapi/wifi_windows.go new file mode 100644 index 0000000..a7e6bf0 --- /dev/null +++ b/client/internal/winapi/wifi_windows.go @@ -0,0 +1,40 @@ +//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 +} diff --git a/client/internal/winapi/winapi.go b/client/internal/winapi/winapi.go index 203c92f..a81d588 100644 --- a/client/internal/winapi/winapi.go +++ b/client/internal/winapi/winapi.go @@ -1,10 +1,7 @@ package winapi import ( - "bytes" - "os/exec" "strings" - "syscall" ) // 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 } -// 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 { idx := strings.Index(line, ":") if idx < 0 { diff --git a/client/internal/winapi/window_darwin.go b/client/internal/winapi/window_darwin.go new file mode 100644 index 0000000..f7c11e2 --- /dev/null +++ b/client/internal/winapi/window_darwin.go @@ -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 +} diff --git a/client/internal/winapi/window_other.go b/client/internal/winapi/window_other.go new file mode 100644 index 0000000..f751ad3 --- /dev/null +++ b/client/internal/winapi/window_other.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package winapi + +func ActivateAppWindow(titleHint string) {} + +func PlayNotifySound() {} + +func ShowWarningMessageBox(title, message string) {} diff --git a/client/test_build b/client/test_build new file mode 100755 index 0000000..290e85c Binary files /dev/null and b/client/test_build differ