Initial commit - ICEYOU v1.2 (production ready, sanitized)
This commit is contained in:
commit
24cd01e46c
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtualenv
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# Config & secrets
|
||||
config.json
|
||||
*.log
|
||||
snapshots/
|
||||
faces/
|
||||
events.log
|
||||
motion_clips/
|
||||
backup/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# PyInstaller
|
||||
*.spec
|
||||
build/
|
||||
dist/
|
||||
495
README.md
Normal file
495
README.md
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
# ICEYOU - Personal Anti-Intrusion Monitor v:1.2
|
||||
|
||||
ICEYOU watches your Windows machine while you're away. The moment any input, USB event, or camera tamper happens after idle, it locks the screen behind a full-screen white overlay with a mandatory password / face-unlock gate, snaps a photo of whoever is at the keyboard, records what they typed, and emails you the evidence.
|
||||
|
||||
## Why do it
|
||||
This project was derived for the traveler or the individual who has a creeper in their proximity. Individuals can allow the system to perform baseline security measures to ensure tampering is not being performed, at an event, a hotel, even at home. Usage is based on a lack of trust with accountability.
|
||||
|
||||
## What It Does
|
||||
|
||||
| Capability | Detail |
|
||||
|---|---|
|
||||
| Idle / away detection | Configurable idle timeout flips ICEYOU into "away" mode |
|
||||
| Whiteout lockout | Full-screen overlay with embedded password + face unlock |
|
||||
| Motion recording | Rolling 5s pre + event + 5s post AVI clips saved to `motion_clips/` while locked |
|
||||
| Escape-key blocking | Low-level Windows keyboard hook blocks CTRL+ESC, ALT+TAB, WIN, ALT+F4, CTRL+SHIFT+ESC, etc. while locked |
|
||||
| Snapshot on attempt | Webcam photo for every unlock attempt (success or failure) |
|
||||
| Credential logging | Failed password attempts saved to `events.log` and emailed |
|
||||
| Face recognition unlock | LBPH-based owner verification with `Unlock by Face` button + auto-attempt |
|
||||
| Camera tamper detection | Triggers full lockdown if lens is covered while locked |
|
||||
| USB / device monitor | Drive insertions/removals during away are treated as intrusions |
|
||||
| Email alerts | SMTP with photo attachment + entered credentials |
|
||||
| OS workstation lock | After N failed password attempts, Windows lock kicks in (whiteout reappears on resume) |
|
||||
| System tray UI | Pause/resume monitoring, force away, send test email, open snapshots/log |
|
||||
| Hotkeys | Force away & re-show unlock prompt globally |
|
||||
|
||||
---
|
||||
|
||||
## Security Notice (Important for Gitea / Remote Hosting)
|
||||
|
||||
**Never commit `config.json`** — it contains your unlock passphrase and email credentials.
|
||||
|
||||
- `config.json` is already listed in `.gitignore`.
|
||||
- Only `config.example.json` (with placeholders) should ever be pushed to Gitea or any remote repository.
|
||||
- Before pushing, always run:
|
||||
```powershell
|
||||
git status
|
||||
git diff --cached --name-only
|
||||
```
|
||||
and verify that `config.json`, `events.log`, `faces/`, `snapshots/`, and `motion_clips/` do **not** appear.
|
||||
|
||||
If you accidentally stage `config.json`, use `git rm --cached config.json` immediately.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Windows 10 or 11** (the DirectShow camera backend and low-level keyboard hook are Windows-specific)
|
||||
- **Python 3.11 or 3.12** (3.11 recommended for best OpenCV compatibility)
|
||||
- A webcam (USB or built-in). Multiple cameras are supported.
|
||||
- (Optional but recommended) A Gmail account with **App Password** enabled for email alerts
|
||||
|
||||
## Full Installation
|
||||
|
||||
### 1. Create and activate a virtual environment
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```powershell
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> **Important**: ICEYOU requires `opencv-contrib-python` (for LBPH face recognition). The requirements file pins a compatible version.
|
||||
|
||||
### 3. Copy the configuration template
|
||||
|
||||
```powershell
|
||||
copy config.example.json config.json
|
||||
notepad.exe config.json
|
||||
```
|
||||
|
||||
Edit at minimum:
|
||||
- `unlock_password` — your secret phrase
|
||||
- `email` section (if you want alerts)
|
||||
- `camera_device_indices` — list the cameras you want to use (run the list command below to discover indices)
|
||||
|
||||
### 3.5 Configuring Gmail for Email Alerts (Requires 2FA)
|
||||
|
||||
Gmail no longer accepts regular account passwords for SMTP. You **must** use an **App Password**.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Go to your Google Account → **Security**
|
||||
2. Enable **2-Step Verification** (if not already enabled)
|
||||
3. Under “Signing in to Google”, click **App passwords**
|
||||
4. Select **Mail** as the app and **Other (Custom name)** → name it `ICEYOU`
|
||||
5. Copy the **16-character App Password** that Google generates (e.g. `abcd efgh ijkl mnop`)
|
||||
6. Paste it into `config.json`:
|
||||
|
||||
```json
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "your.email@gmail.com",
|
||||
"password": "abcd efgh ijkl mnop", // ← the 16-char App Password (spaces optional)
|
||||
"from_addr": "your.email@gmail.com",
|
||||
"to_addr": "your.alerts@domain.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Regular password → “Username and Password not accepted” error.
|
||||
- The App Password is **not** your normal Gmail password.
|
||||
- You can revoke App Passwords anytime from the same Google Security page.
|
||||
- Gmail often delivers the first few alerts to **Spam** — mark them “Not spam” so future alerts go to Inbox.
|
||||
|
||||
### 4. Discover available cameras (optional but recommended)
|
||||
|
||||
```powershell
|
||||
python face_enrollment.py --list-cameras
|
||||
```
|
||||
|
||||
Note the indices of your real webcams (ignore virtual cameras like OBS unless you want them).
|
||||
|
||||
### 5. (Strongly Recommended) Enroll your face for password-less unlock
|
||||
|
||||
Run the enrollment tool multiple times with different appearances / accessories / hair styles:
|
||||
|
||||
```powershell
|
||||
# Basic enrollment (no accessories)
|
||||
python face_enrollment.py --name owner
|
||||
|
||||
# With glasses
|
||||
python face_enrollment.py --name owner_glasses
|
||||
|
||||
# With headphones
|
||||
python face_enrollment.py --name owner_headphone
|
||||
|
||||
# With both glasses + headphones + hair in a bun
|
||||
python face_enrollment.py --name owner_headphones_bun
|
||||
```
|
||||
|
||||
Each run captures 5 poses (straight, left, right, up, down). The tool automatically retrains `faces/model.yml`.
|
||||
|
||||
**Tips for good enrollment**:
|
||||
- Good, even lighting (avoid strong back-lighting)
|
||||
- Neutral expression + slight smile
|
||||
- Hold still for ~2 seconds per pose
|
||||
- Remove the camera cover / ensure the lens is clean
|
||||
|
||||
After enrollment you should see:
|
||||
- `faces/owner/` containing many `.png` images
|
||||
- `faces/model.yml` (the trained model)
|
||||
- `faces/labels.json`
|
||||
|
||||
### 6. Enable face unlock (optional)
|
||||
|
||||
In `config.json`:
|
||||
|
||||
```json
|
||||
"face_recognition_enabled": true,
|
||||
"face_unlock": {
|
||||
"enabled": true,
|
||||
"confidence_threshold": 70.0,
|
||||
"min_successful_matches": 2,
|
||||
"auto_attempt": true
|
||||
}
|
||||
```
|
||||
|
||||
- `min_successful_matches: 2` requires two successful frames before unlocking (protects against look-alikes).
|
||||
- Lower `confidence_threshold` = stricter matching.
|
||||
|
||||
### 7. Run ICEYOU
|
||||
|
||||
```powershell
|
||||
python main.py
|
||||
```
|
||||
|
||||
A red-shield tray icon appears in the system tray. Right-click it for the full menu.
|
||||
|
||||
### 8. (Optional) Add to Windows Startup
|
||||
|
||||
Create a shortcut to `main.py` (or use the `startup.py` helper if present) and place it in:
|
||||
|
||||
```
|
||||
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
|
||||
```
|
||||
|
||||
Or run as a scheduled task (recommended for reliability).
|
||||
|
||||
## First Run Checklist
|
||||
|
||||
- [ ] `config.json` has a strong `unlock_password`
|
||||
- [ ] Email settings tested with tray → **Send Test Email**
|
||||
- [ ] At least one camera index works (`camera_device_indices`)
|
||||
- [ ] Face model trained and `face_unlock.enabled: true` (if desired)
|
||||
- [ ] White screen dimming settings reviewed (`white_screen_dimming`)
|
||||
- [ ] Motion recording enabled (default = on)
|
||||
- [ ] Test the full flow: Force away (`Ctrl+Alt+L`) → wait for whiteout → type wrong password → verify email arrives with snapshot
|
||||
|
||||
## Generated Files & Directories (Ignored by Git)
|
||||
|
||||
| Path | Purpose | Safe to delete? |
|
||||
|-----------------------|----------------------------------------------|---------------------|
|
||||
| `faces/` | Enrollment photos + trained `model.yml` | Yes (re-enroll) |
|
||||
| `snapshots/` | Intrusion & unlock attempt photos | Yes |
|
||||
| `motion_clips/` | Pre/post-motion AVI recordings | Yes |
|
||||
| `events.log` | JSON audit trail of all events | Yes (rotates) |
|
||||
| `config.json` | Your personal settings (never commit) | No (backup first) |
|
||||
|
||||
---
|
||||
|
||||
## Hotkeys & Manual Control
|
||||
|
||||
| Hotkey (default) | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Alt+L` | Force away mode + show whiteout immediately |
|
||||
| `Ctrl+Alt+U` | Re-show the whiteout password prompt (useful if it was hidden) |
|
||||
|
||||
Combos are configurable in `config.json → hotkeys` using pynput syntax (`<ctrl>+<shift>+<alt>+p`, etc.). Restart ICEYOU after editing.
|
||||
|
||||
---
|
||||
|
||||
## Tray Menu
|
||||
|
||||
| Item | Description |
|
||||
|---|---|
|
||||
| Start / Stop Monitoring | Toggle monitor thread |
|
||||
| Toggle Away Mode | Manual away on/off (away forces the whiteout) |
|
||||
| Toggle White Screen | Disable the whiteout while still monitoring |
|
||||
| Open Snapshots Folder | Browse all captured photos |
|
||||
| View Motion Clips | Open `motion_clips/` (post-auth in practice) |
|
||||
| Open Log File | View `events.log` |
|
||||
| **Send Test Email** | Sanity-check SMTP without triggering an intrusion |
|
||||
| Settings | Opens `config.json` in Notepad (restart to apply) |
|
||||
| Exit | Stops everything cleanly |
|
||||
|
||||
---
|
||||
|
||||
## Intrusion Response Flow
|
||||
|
||||
1. **Detect** — Idle ≥ `idle_timeout_seconds` (or you pressed `Ctrl+Alt+L`) → away mode + whiteout up.
|
||||
2. **Trigger** — Any keyboard, mouse, USB, or camera-obscured event during away fires an intrusion:
|
||||
- Snapshot captured (saved to `snapshots/`)
|
||||
- `intrusion_detected` event logged
|
||||
- Email alert sent with photo
|
||||
- Whiteout stays up with the password / face-unlock prompt
|
||||
3. **Locked state** — `manual_lock` is set; ordinary activity **cannot** clear away mode. Only a correct unlock does.
|
||||
4. **Password gate** — Cannot be cancelled or closed. After `max_unlock_attempts` (default 3) wrong passwords, Windows workstation lock kicks in. When the user returns from the Windows lock screen the **ICEYOU whiteout is still there** — unlocking Windows alone is not enough.
|
||||
5. **Success** — Whiteout dismisses, failure counter resets, auto-away is snoozed briefly so you can work.
|
||||
|
||||
---
|
||||
|
||||
## Unlock Attempt Audit Trail
|
||||
|
||||
Every interaction with the unlock prompt is recorded:
|
||||
|
||||
- **Snapshot** of whoever is at the keyboard: `snapshots/snapshot_<timestamp>_unlock_<success|fail>_<password|face>.jpg`
|
||||
- **Structured log entry** appended to `events.log` (one JSON object per line):
|
||||
- `method` — `"password"` or `"face"`
|
||||
- `success` — boolean
|
||||
- `snapshot` — path to the photo
|
||||
- `entered` — **only on failed password attempts** — the literal text the intruder typed
|
||||
- `label`, `confidence` — for face attempts
|
||||
- **Email alert** (if `email.enabled`) with the snapshot attached:
|
||||
- Failed password attempts include the typed text in the body
|
||||
- Successful unlocks confirm but **never** include your real password
|
||||
- Auto face attempts (every 4 s) are silent (no log / no email) to avoid flooding. Only **manual** `Unlock by Face` clicks and **successful** matches are recorded.
|
||||
|
||||
If alerts aren't arriving: tray → **Send Test Email** (or `python test_email.py`). SMTP errors land in `events.log` as `email_failed`. Gmail → external-domain alerts often hit **Spam** — check there first and mark "Not spam" to whitelist.
|
||||
|
||||
---
|
||||
|
||||
## Face Recognition Unlock
|
||||
|
||||
Optional second unlock path next to the password.
|
||||
|
||||
1. **Install the contrib OpenCV build** (required for `cv2.face`):
|
||||
```powershell
|
||||
pip install --upgrade --force-reinstall opencv-contrib-python
|
||||
```
|
||||
|
||||
2. **Enroll** your face — multi-pose, multi-look:
|
||||
```powershell
|
||||
python face_enrollment.py --name owner
|
||||
python face_enrollment.py --camera 1 --list-cameras
|
||||
```
|
||||
The capture window cycles through 5 poses (straight, left, right, up, down). Controls:
|
||||
- **Click** the preview window — most reliable capture
|
||||
- **SPACE / ENTER / C** — capture (window must have focus)
|
||||
- Hold a steady face ≥ 2 s — auto-capture
|
||||
- **R** restart, **Q / ESC** quit
|
||||
|
||||
For glasses / headphones / hats, **re-run with the same `--name`** so LBPH learns all your looks under one identity. Each session appends to `faces/<label>/capture_NN.png` and re-trains `faces/model.yml`.
|
||||
|
||||
3. **Enable in `config.json`**:
|
||||
```json
|
||||
"face_recognition_enabled": true,
|
||||
"face_unlock": {
|
||||
"enabled": true,
|
||||
"confidence_threshold": 85.0,
|
||||
"auto_attempt": true,
|
||||
"attempt_interval_seconds": 4.0,
|
||||
"frames_per_attempt": 4,
|
||||
"allowed_labels": ["owner"]
|
||||
}
|
||||
```
|
||||
|
||||
- `confidence_threshold` — **lower = stricter** (LBPH distance). 50 = very strong, 70 = balanced, 85 = lenient. Tune based on the `conf=…` value shown on misses.
|
||||
- `allowed_labels` — list of enrolled identities allowed to unlock. `null` = any enrolled label.
|
||||
- `auto_attempt` — background tries every `attempt_interval_seconds` while the whiteout is up.
|
||||
|
||||
While the whiteout is shown:
|
||||
- Auto-attempts run silently every 4 s (default).
|
||||
- The **Unlock by Face** button lets you trigger a manual attempt (this is logged + emailed; auto attempts are not).
|
||||
- Face failures do **not** count toward the 3-strike OS-lock — only typed passwords do, so a bad camera angle can never lock you out.
|
||||
|
||||
**Status hint**: failed face attempts show `Face not recognised (best=<label>, conf=<value>)` on the whiteout. If you consistently see `conf` slightly above your threshold, either raise the threshold or enroll more samples.
|
||||
|
||||
---
|
||||
|
||||
## Camera Tamper Detection
|
||||
|
||||
When the whiteout is active, ICEYOU periodically samples the camera (default every 12 s). If the frame mean brightness or pixel variance drops below thresholds (lens covered, sticker over webcam, etc.), it immediately triggers full lockdown — email alert + OS workstation lock — **without** waiting for input. Configure in `config.json → camera_obscured_detection`.
|
||||
|
||||
---
|
||||
|
||||
## Motion Recording
|
||||
|
||||
While the whiteout is up, [src/iceyou/motion_recorder.py](src/iceyou/motion_recorder.py) continuously samples the webcam and keeps a **rolling 5 s pre-buffer** of frames. When motion is detected (frame differencing above `sensitivity`), it writes the buffered pre-roll + the event + 5 s of post-roll to `motion_clips/motion_<timestamp>.avi` (MJPG codec). Each finalized clip:
|
||||
|
||||
- Logs a `motion_recorded` line to `events.log` with the clip path.
|
||||
- Pops a system-tray notification ("Motion event saved: motion_…avi").
|
||||
- Does **not** trigger an email (snapshots + unlock attempts already cover that).
|
||||
|
||||
Recording starts on `WhiteScreen.show()` and stops on `WhiteScreen.hide()` — there is no recording when ICEYOU is not in the locked state.
|
||||
|
||||
**Viewing clips post-authentication:** the tray menu has a **View Motion Clips** item that opens the `motion_clips/` folder in Explorer. While the whiteout is up the escape-key hook prevents the intruder from reaching the tray icon, so this item is effectively post-authentication. Once you unlock, right-click the tray icon and pick **View Motion Clips** to review.
|
||||
|
||||
Config (`config.json → motion_recording`):
|
||||
|
||||
```jsonc
|
||||
"motion_recording": {
|
||||
"enabled": true,
|
||||
"clips_dir": "motion_clips",
|
||||
"fps": 15,
|
||||
"pre_seconds": 5.0,
|
||||
"post_seconds": 5.0,
|
||||
"sensitivity": 2500, // min contour area in pixels; lower = more sensitive
|
||||
"cooldown_seconds": 8.0 // min gap between separate clips
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Escape-Key Blocking
|
||||
|
||||
While the whiteout is shown, a low-level Windows keyboard hook ([src/iceyou/keyboard_block.py](src/iceyou/keyboard_block.py)) swallows:
|
||||
|
||||
- `WIN` (left + right)
|
||||
- `CTRL+ESC` (Start menu)
|
||||
- `ALT+TAB`, `ALT+ESC` (task switching)
|
||||
- `ALT+F4` (close window)
|
||||
- `ALT+SPACE` (window menu)
|
||||
- `CTRL+SHIFT+ESC` (Task Manager hotkey)
|
||||
|
||||
Disable with `"block_escape_keys": false` if needed (e.g. development). The hook is removed automatically on unlock.
|
||||
|
||||
**Hard limits (Windows-enforced, cannot be intercepted from user mode):**
|
||||
|
||||
- `CTRL+ALT+DEL` — Secure Attention Sequence is kernel-enforced. An intruder can still reach the security screen → Task Manager → kill `python.exe`.
|
||||
- Hard power off, pulling the keyboard.
|
||||
- Logging in as a different Windows user.
|
||||
|
||||
To harden against CTRL+ALT+DEL → Task Manager, run as Administrator and set the registry value `HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System\DisableTaskMgr = 1 (DWORD)`. ICEYOU does **not** apply this automatically because it's a system-wide setting affecting every program.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference (`config.json`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"idle_timeout_seconds": 100, // Idle seconds before auto-away
|
||||
"monitoring_enabled": true, // Master on/off (start in monitoring mode)
|
||||
"camera_device_index": 1, // (Legacy single-cam fallback - used only if camera_device_indices is omitted)
|
||||
"camera_device_indices": [0, 1], // Multi-cam: probe each of these. Dead indices are auto-skipped.
|
||||
"camera_probe_timeout_seconds": 2.5, // Per-camera open+first-frame budget; phantom devices that block longer are marked DEAD
|
||||
"snapshot_on_trigger": true, // Save a photo on every intrusion / attempt (one per alive camera)
|
||||
"snapshot_dir": "snapshots",
|
||||
"log_file": "events.log",
|
||||
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "you@gmail.com",
|
||||
"password": "YOUR_16_CHAR_APP_PASSWORD", // Gmail App Password (requires 2FA)
|
||||
"from_addr": "you@gmail.com",
|
||||
"to_addr": "alerts@you.com",
|
||||
"subject_prefix": "[ICEYOU Alert]"
|
||||
},
|
||||
|
||||
"lock_workstation": true, // OS lock after max_unlock_attempts password fails
|
||||
"device_monitoring": true, // Watch USB drives
|
||||
"white_screen_on_away": true, // Show the whiteout when away
|
||||
"unlock_password": "CHANGE_ME_STRONG_PASSPHRASE", // Your secret phrase
|
||||
"max_unlock_attempts": 3, // Wrong passwords before OS lock
|
||||
"block_escape_keys": true, // Install OS keyboard hook while locked
|
||||
|
||||
"camera_obscured_detection": {
|
||||
"enabled": true,
|
||||
"brightness_threshold": 25,
|
||||
"variance_threshold": 10,
|
||||
"check_interval_seconds": 6
|
||||
},
|
||||
|
||||
"hotkeys": {
|
||||
"force_away": "<ctrl>+<alt>+l",
|
||||
"unlock": "<ctrl>+<alt>+u"
|
||||
},
|
||||
|
||||
"face_recognition_enabled": true,
|
||||
"face_unlock": {
|
||||
"enabled": true,
|
||||
"confidence_threshold": 85.0, // Lower = stricter (LBPH distance)
|
||||
"auto_attempt": true,
|
||||
"attempt_interval_seconds": 4.0,
|
||||
"frames_per_attempt": 4,
|
||||
"allowed_labels": ["owner"] // null = accept any enrolled label
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| No tray icon | Make sure `python main.py` is running; check `events.log` for startup errors. |
|
||||
| Email alerts not arriving | Check Spam folder. Use **Send Test Email** in tray (or `python test_email.py`). Look for `email_failed` in `events.log`. |
|
||||
| Face unlock never matches | Status line shows `conf=<value>`. Either raise `confidence_threshold` in `config.json` or re-enroll with more samples / better lighting / same accessories you're wearing now. |
|
||||
| Password field clears mid-typing | Already fixed — input while the whiteout is up is treated as challenge, not re-intrusion. Pull latest `monitor.py` + `white_screen.py`. |
|
||||
| Tk error `Tcl_AsyncDelete: async handler deleted by the wrong thread` | Already fixed — `WhiteScreen` uses a persistent Tk root on a dedicated thread. |
|
||||
| Tk error `can't set fullscreen attribute… override-redirect flag is set` | Already fixed — `WhiteScreen` sizes to screen via `geometry()` instead of `-fullscreen`. |
|
||||
| `numpy<2` conflict with `opencv-contrib-python` | Use the recommended `.venv` virtualenv. |
|
||||
| Antivirus flags ICEYOU | Low-level keyboard hooks trigger heuristics. Whitelist the project folder. |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ICEYOU/
|
||||
├── main.py Tray app entrypoint
|
||||
├── face_enrollment.py Standalone face capture + LBPH training
|
||||
├── test_email.py CLI SMTP sanity check
|
||||
├── config.json Runtime settings (gitignored)
|
||||
├── config.example.json Template
|
||||
├── events.log JSON-lines event audit trail
|
||||
├── snapshots/ All captured webcam photos
|
||||
├── motion_clips/ 5s-before + event + 5s-after AVI clips from the whiteout
|
||||
├── faces/
|
||||
│ ├── model.yml Trained LBPH model
|
||||
│ ├── labels.json Label id → name map
|
||||
│ └── <label>/capture_NN.png Enrolled photos per identity
|
||||
└── src/iceyou/
|
||||
├── tray_app.py System tray + wiring
|
||||
├── monitor.py Away state machine + intrusion trigger
|
||||
├── white_screen.py Persistent Tk fullscreen lockout + unlock UI
|
||||
├── face_recognizer.py LBPH recognizer with multi-crop verification
|
||||
├── motion_recorder.py Rolling-buffer motion clips while locked
|
||||
├── keyboard_block.py Low-level Windows keyboard hook
|
||||
├── camera.py Webcam capture, snapshots, obscured detection
|
||||
├── input_hooks.py pynput keyboard + mouse global hooks
|
||||
├── device_monitor.py Drive insertion/removal poller
|
||||
├── actions.py Email alerts (intrusion + unlock attempts)
|
||||
├── utils.py Win32 idle time, lock workstation, timestamps
|
||||
├── startup.py HKCU Run registry add/remove
|
||||
└── config.py Deep-merged config loader with dotted-key get
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Use
|
||||
|
||||
ICEYOU accesses your camera, keyboard, mouse, and writes intruder photos + entered text to local disk and email. It is intended **strictly for protecting your own machine**. Do not use it for unauthorized surveillance of others.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Encrypted at-rest storage of snapshots and log
|
||||
- Optional cloud upload of evidence
|
||||
- Windows service mode (run without an interactive session)
|
||||
- Web dashboard for remote review
|
||||
- DNN-based face recognition (FaceNet / dlib) for higher accuracy than LBPH
|
||||
65
config.example.json
Normal file
65
config.example.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"idle_timeout_seconds": 300,
|
||||
"monitoring_enabled": true,
|
||||
"camera_device_index": 0,
|
||||
"camera_device_indices": [0],
|
||||
"camera_probe_timeout_seconds": 2.5,
|
||||
"snapshot_on_trigger": true,
|
||||
"snapshot_dir": "snapshots",
|
||||
"log_file": "events.log",
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "your.email@gmail.com",
|
||||
"password": "YOUR_APP_PASSWORD_HERE",
|
||||
"from_addr": "your.email@gmail.com",
|
||||
"to_addr": "your.email@gmail.com",
|
||||
"subject_prefix": "[ICEYOU Alert]"
|
||||
},
|
||||
"lock_workstation": true,
|
||||
"device_monitoring": true,
|
||||
"white_screen_on_away": true,
|
||||
"unlock_password": "iceyou2026",
|
||||
"max_unlock_attempts": 3,
|
||||
"block_escape_keys": true,
|
||||
"camera_obscured_detection": {
|
||||
"enabled": true,
|
||||
"brightness_threshold": 35,
|
||||
"variance_threshold": 10,
|
||||
"check_interval_seconds": 12
|
||||
},
|
||||
"hotkeys": {
|
||||
"force_away": "<ctrl>+<alt>+l",
|
||||
"unlock": "<ctrl>+<alt>+u"
|
||||
},
|
||||
"face_recognition_enabled": false,
|
||||
"face_recognition": {
|
||||
"tolerance": 0.6,
|
||||
"enrollment_images_dir": "faces/owner"
|
||||
},
|
||||
"face_unlock": {
|
||||
"enabled": false,
|
||||
"confidence_threshold": 70.0,
|
||||
"auto_attempt": true,
|
||||
"attempt_interval_seconds": 4.0,
|
||||
"frames_per_attempt": 4,
|
||||
"min_successful_matches": 2,
|
||||
"allowed_labels": null
|
||||
},
|
||||
"motion_recording": {
|
||||
"enabled": true,
|
||||
"clips_dir": "motion_clips",
|
||||
"fps": 15,
|
||||
"pre_seconds": 5.0,
|
||||
"post_seconds": 5.0,
|
||||
"sensitivity": 2500,
|
||||
"cooldown_seconds": 8.0
|
||||
},
|
||||
"white_screen_dimming": {
|
||||
"enabled": true,
|
||||
"duration_seconds": 60,
|
||||
"target_grey": "#000000"
|
||||
}
|
||||
}
|
||||
361
face_enrollment.py
Normal file
361
face_enrollment.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"""ICEYOU - Authorized User Face Enrollment
|
||||
|
||||
Captures multiple webcam photos of the authorized user from various angles,
|
||||
saves them under faces/owner/, and trains an OpenCV LBPH face-recognition model.
|
||||
|
||||
Usage:
|
||||
python face_enrollment.py
|
||||
(optionally append --name <label> to enroll secondary authorized users)
|
||||
|
||||
Controls in the live preview window:
|
||||
SPACE - Capture the current frame (only if a face is detected)
|
||||
R - Restart enrollment
|
||||
Q/ESC - Quit
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import cv2
|
||||
|
||||
|
||||
REQUIRED_POSES = [
|
||||
"Look straight at the camera",
|
||||
"Slowly turn your head to the LEFT",
|
||||
"Slowly turn your head to the RIGHT",
|
||||
"Tilt your head slightly UP",
|
||||
"Tilt your head slightly DOWN",
|
||||
]
|
||||
|
||||
MIN_CAPTURES = 3
|
||||
TARGET_CAPTURES = len(REQUIRED_POSES)
|
||||
|
||||
FACES_DIR = Path("faces")
|
||||
MODEL_FILE = FACES_DIR / "model.yml"
|
||||
LABELS_FILE = FACES_DIR / "labels.json"
|
||||
|
||||
|
||||
def load_face_detectors():
|
||||
"""Load both frontal and profile Haar cascades for better coverage."""
|
||||
base = Path(cv2.data.haarcascades)
|
||||
frontal_path = base / "haarcascade_frontalface_default.xml"
|
||||
alt_path = base / "haarcascade_frontalface_alt2.xml"
|
||||
profile_path = base / "haarcascade_profileface.xml"
|
||||
if not frontal_path.exists():
|
||||
print(f"ERROR: Haar cascade not found at {frontal_path}")
|
||||
sys.exit(1)
|
||||
detectors = {
|
||||
"frontal": cv2.CascadeClassifier(str(frontal_path)),
|
||||
"alt2": cv2.CascadeClassifier(str(alt_path)) if alt_path.exists() else None,
|
||||
"profile": cv2.CascadeClassifier(str(profile_path)) if profile_path.exists() else None,
|
||||
}
|
||||
return detectors
|
||||
|
||||
|
||||
def detect_face(detectors: dict, gray):
|
||||
"""Run multiple cascades and return list of face rects.
|
||||
Tries frontal first, then alt2, then profile (also mirrored for the other side).
|
||||
"""
|
||||
# Improve contrast for low-light conditions
|
||||
eq = cv2.equalizeHist(gray)
|
||||
|
||||
params = dict(scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))
|
||||
|
||||
faces = detectors["frontal"].detectMultiScale(eq, **params)
|
||||
if len(faces) == 0 and detectors["alt2"] is not None:
|
||||
faces = detectors["alt2"].detectMultiScale(eq, **params)
|
||||
if len(faces) == 0 and detectors["profile"] is not None:
|
||||
faces = detectors["profile"].detectMultiScale(eq, **params)
|
||||
if len(faces) == 0:
|
||||
# Mirror to detect the opposite-facing profile
|
||||
flipped = cv2.flip(eq, 1)
|
||||
faces = detectors["profile"].detectMultiScale(flipped, **params)
|
||||
if len(faces) > 0:
|
||||
w = eq.shape[1]
|
||||
faces = [(w - x - fw, y, fw, fh) for (x, y, fw, fh) in faces]
|
||||
return faces
|
||||
|
||||
|
||||
def run_capture_session(user_label: str, user_dir: Path, camera_index: int = 0) -> List[Path]:
|
||||
"""Open camera, prompt for poses, save captures. Returns list of saved paths."""
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Opening camera index {camera_index}...")
|
||||
cap = cv2.VideoCapture(camera_index, cv2.CAP_DSHOW)
|
||||
if not cap.isOpened():
|
||||
print(f"ERROR: Could not open webcam at index {camera_index}.")
|
||||
print("Try a different --camera value (0, 1, 2, ...).")
|
||||
sys.exit(1)
|
||||
|
||||
detectors = load_face_detectors()
|
||||
captures: List[Path] = []
|
||||
pose_idx = 0
|
||||
|
||||
# Capture trigger flag (set by mouse click, auto-capture, or SPACE key)
|
||||
capture_requested = [False]
|
||||
|
||||
def on_mouse(event, x, y, flags, param):
|
||||
if event == cv2.EVENT_LBUTTONDOWN:
|
||||
capture_requested[0] = True
|
||||
|
||||
print("\n=== ICEYOU Face Enrollment ===")
|
||||
print(f"User label: {user_label}")
|
||||
print(f"Target captures: {TARGET_CAPTURES} (minimum {MIN_CAPTURES})")
|
||||
print("Controls:")
|
||||
print(" - CLICK the preview window = capture (most reliable)")
|
||||
print(" - SPACE / ENTER / C = capture (window must have focus)")
|
||||
print(" - Auto-capture: hold a steady face for ~2s = capture automatically")
|
||||
print(" - R = restart, Q/ESC = quit\n")
|
||||
|
||||
win_name = f"ICEYOU Enrollment - {user_label}"
|
||||
cv2.namedWindow(win_name, cv2.WINDOW_NORMAL)
|
||||
cv2.setMouseCallback(win_name, on_mouse)
|
||||
|
||||
import time
|
||||
steady_face_start = None # timestamp when a face first became visible & still
|
||||
|
||||
try:
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Camera read failed.")
|
||||
break
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
faces = detect_face(detectors, gray)
|
||||
face_detected = len(faces) > 0
|
||||
|
||||
display = frame.copy()
|
||||
for (x, y, w, h) in faces:
|
||||
cv2.rectangle(display, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
||||
|
||||
done = pose_idx >= TARGET_CAPTURES
|
||||
instruction = "ENROLLMENT COMPLETE - press Q to finish" if done else REQUIRED_POSES[pose_idx]
|
||||
|
||||
# Auto-capture: 2-second steady face countdown
|
||||
auto_countdown_remaining = None
|
||||
if not done and face_detected:
|
||||
if steady_face_start is None:
|
||||
steady_face_start = time.time()
|
||||
elapsed = time.time() - steady_face_start
|
||||
auto_countdown_remaining = max(0.0, 2.0 - elapsed)
|
||||
if elapsed >= 2.0:
|
||||
capture_requested[0] = True
|
||||
steady_face_start = None
|
||||
else:
|
||||
steady_face_start = None
|
||||
|
||||
overlay = display.copy()
|
||||
cv2.rectangle(overlay, (0, 0), (display.shape[1], 110), (0, 0, 0), -1)
|
||||
cv2.addWeighted(overlay, 0.6, display, 0.4, 0, display)
|
||||
|
||||
cv2.putText(display, f"[{len(captures)}/{TARGET_CAPTURES}] {instruction}",
|
||||
(10, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
|
||||
if face_detected:
|
||||
status = f"Face detected ({len(faces)}) - CLICK or SPACE"
|
||||
color = (0, 255, 0)
|
||||
else:
|
||||
status = "No face auto-detected - CLICK / SPACE = manual center crop"
|
||||
color = (0, 200, 255)
|
||||
cv2.putText(display, status, (10, 70),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
|
||||
if auto_countdown_remaining is not None and auto_countdown_remaining > 0:
|
||||
cv2.putText(display, f"Auto-capture in {auto_countdown_remaining:.1f}s",
|
||||
(10, 100), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 0), 2)
|
||||
|
||||
# Draw center-crop guide when no face detected (helps user frame themselves)
|
||||
if not face_detected:
|
||||
h, w = display.shape[:2]
|
||||
side = min(h, w) * 6 // 10
|
||||
cx0, cy0 = (w - side) // 2, (h - side) // 2
|
||||
cv2.rectangle(display, (cx0, cy0), (cx0 + side, cy0 + side), (0, 200, 255), 2)
|
||||
|
||||
cv2.imshow(win_name, display)
|
||||
key = cv2.waitKey(30) & 0xFF
|
||||
|
||||
# Capture keys: SPACE (32), ENTER (13), C (99)
|
||||
capture_keys = {32, 13, ord("c"), ord("C")}
|
||||
quit_keys = {ord("q"), ord("Q"), 27}
|
||||
restart_keys = {ord("r"), ord("R")}
|
||||
|
||||
if key in quit_keys:
|
||||
break
|
||||
elif key in restart_keys:
|
||||
for p in captures:
|
||||
try:
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
captures.clear()
|
||||
pose_idx = 0
|
||||
print("Restarting enrollment...")
|
||||
elif key in capture_keys:
|
||||
capture_requested[0] = True
|
||||
elif key not in (255, 0):
|
||||
print(f" (unrecognized key code: {key})")
|
||||
|
||||
# Handle the capture request from any source (mouse, key, auto-capture)
|
||||
if capture_requested[0]:
|
||||
capture_requested[0] = False
|
||||
if done:
|
||||
print("Enrollment already complete - press Q to finish.")
|
||||
continue
|
||||
if face_detected:
|
||||
(x, y, w, h) = max(faces, key=lambda f: f[2] * f[3])
|
||||
m = int(0.1 * max(w, h))
|
||||
x0 = max(x - m, 0)
|
||||
y0 = max(y - m, 0)
|
||||
x1 = min(x + w + m, gray.shape[1])
|
||||
y1 = min(y + h + m, gray.shape[0])
|
||||
face_crop = gray[y0:y1, x0:x1]
|
||||
crop_mode = "detected"
|
||||
else:
|
||||
h, w = gray.shape
|
||||
side = min(h, w) * 6 // 10
|
||||
cy0 = (h - side) // 2
|
||||
cx0 = (w - side) // 2
|
||||
face_crop = gray[cy0:cy0 + side, cx0:cx0 + side]
|
||||
crop_mode = "manual-center"
|
||||
face_resized = cv2.resize(face_crop, (200, 200))
|
||||
|
||||
filename = user_dir / f"capture_{pose_idx + 1:02d}.png"
|
||||
cv2.imwrite(str(filename), face_resized)
|
||||
captures.append(filename)
|
||||
print(f" Captured ({crop_mode}): {filename.name} ({REQUIRED_POSES[pose_idx]})")
|
||||
pose_idx += 1
|
||||
steady_face_start = None # reset auto-capture so it waits again
|
||||
finally:
|
||||
cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
return captures
|
||||
|
||||
|
||||
def train_model(faces_root: Path) -> bool:
|
||||
"""Train an LBPH model on all images under faces_root/<label>/*.png.
|
||||
Saves to faces/model.yml and labels.json (label_id -> label_name).
|
||||
Requires opencv-contrib-python (cv2.face module).
|
||||
"""
|
||||
try:
|
||||
recognizer = cv2.face.LBPHFaceRecognizer_create()
|
||||
except AttributeError:
|
||||
print("\nWARNING: cv2.face is unavailable. Install opencv-contrib-python:")
|
||||
print(" pip install --upgrade --force-reinstall opencv-contrib-python")
|
||||
print("Photos are saved but recognition model NOT trained.")
|
||||
return False
|
||||
|
||||
samples = []
|
||||
sample_labels = []
|
||||
label_map = {}
|
||||
|
||||
user_dirs = sorted([d for d in faces_root.iterdir() if d.is_dir()])
|
||||
if not user_dirs:
|
||||
print("No user directories to train from.")
|
||||
return False
|
||||
|
||||
for label_id, user_dir in enumerate(user_dirs):
|
||||
label_map[label_id] = user_dir.name
|
||||
images = sorted(user_dir.glob("*.png")) + sorted(user_dir.glob("*.jpg"))
|
||||
for img_path in images:
|
||||
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
continue
|
||||
samples.append(img)
|
||||
sample_labels.append(label_id)
|
||||
|
||||
if not samples:
|
||||
print("No samples found to train.")
|
||||
return False
|
||||
|
||||
import numpy as np
|
||||
recognizer.train(samples, np.array(sample_labels))
|
||||
MODEL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
recognizer.save(str(MODEL_FILE))
|
||||
with open(LABELS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({str(k): v for k, v in label_map.items()}, f, indent=2)
|
||||
|
||||
print(f"\nTrained on {len(samples)} samples across {len(label_map)} user(s).")
|
||||
print(f"Model saved: {MODEL_FILE}")
|
||||
print(f"Labels saved: {LABELS_FILE}")
|
||||
return True
|
||||
|
||||
|
||||
def _default_camera_index() -> int:
|
||||
"""Read camera_device_index from config.json if present, else 0."""
|
||||
cfg_path = Path("config.json")
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
return int(json.load(f).get("camera_device_index", 0))
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def list_available_cameras(max_index: int = 5) -> List[int]:
|
||||
"""Probe camera indices 0..max_index-1 and return those that open successfully."""
|
||||
available = []
|
||||
for i in range(max_index):
|
||||
cap = cv2.VideoCapture(i, cv2.CAP_DSHOW)
|
||||
if cap.isOpened():
|
||||
available.append(i)
|
||||
cap.release()
|
||||
return available
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ICEYOU face enrollment")
|
||||
parser.add_argument("--name", default="owner",
|
||||
help="Label for this user (default: owner)")
|
||||
parser.add_argument("--camera", "-c", type=int, default=None,
|
||||
help="Camera device index (default: from config.json or 0)")
|
||||
parser.add_argument("--list-cameras", action="store_true",
|
||||
help="Probe and list available camera indices, then exit")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_cameras:
|
||||
cams = list_available_cameras()
|
||||
if cams:
|
||||
print(f"Available camera indices: {cams}")
|
||||
else:
|
||||
print("No cameras detected.")
|
||||
return
|
||||
|
||||
camera_index = args.camera if args.camera is not None else _default_camera_index()
|
||||
user_label = args.name.strip().replace(" ", "_") or "owner"
|
||||
user_dir = FACES_DIR / user_label
|
||||
|
||||
if user_dir.exists() and any(user_dir.iterdir()):
|
||||
resp = input(f"Existing enrollment for '{user_label}' found. Overwrite? [y/N]: ").strip().lower()
|
||||
if resp != "y":
|
||||
print("Aborted.")
|
||||
return
|
||||
for p in user_dir.glob("*"):
|
||||
try:
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
captures = run_capture_session(user_label, user_dir, camera_index=camera_index)
|
||||
|
||||
if len(captures) < MIN_CAPTURES:
|
||||
print(f"\nOnly {len(captures)} photo(s) captured. Need at least {MIN_CAPTURES}.")
|
||||
print("Enrollment not finalized.")
|
||||
return
|
||||
|
||||
print(f"\n{len(captures)} photo(s) saved to {user_dir}")
|
||||
print("Training recognition model...")
|
||||
train_model(FACES_DIR)
|
||||
|
||||
print("\nEnrollment complete.")
|
||||
print("To enable recognition, set in config.json:")
|
||||
print(' "face_recognition_enabled": true')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
main.py
Normal file
20
main.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""ICEYOU entry point - launches the system tray application."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
from iceyou.tray_app import TrayApp
|
||||
|
||||
|
||||
def main():
|
||||
print("Starting ICEYOU Personal Monitor...")
|
||||
print("A system tray icon will appear. Right-click for options.")
|
||||
app = TrayApp()
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
requirements.txt
Normal file
16
requirements.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# ICEYOU - Personal System Monitor & Intrusion Defense
|
||||
# Core dependencies for camera, input hooks, device monitoring, tray, Windows integration
|
||||
|
||||
opencv-contrib-python>=4.9.0.80
|
||||
pynput>=1.7.6
|
||||
pywin32>=306
|
||||
pystray>=0.19.5
|
||||
Pillow>=10.3.0
|
||||
python-json-logger>=2.0.7
|
||||
|
||||
# Optional but recommended for email alerts (uses stdlib smtplib + ssl)
|
||||
# For face recognition (advanced "not me" detection):
|
||||
# face-recognition>=1.3.0 # Requires dlib + build tools on Windows; see README
|
||||
|
||||
# Note: Run `pip install -r requirements.txt`
|
||||
# For full face_recognition on Windows: Install Visual Studio Build Tools, CMake first.
|
||||
3
src/iceyou/__init__.py
Normal file
3
src/iceyou/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""ICEYOU - Personal System Intrusion Defense Monitor"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
175
src/iceyou/actions.py
Normal file
175
src/iceyou/actions.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Action handlers triggered on intrusion detection: email, lock, etc."""
|
||||
|
||||
import smtplib
|
||||
import ssl
|
||||
import json
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import threading
|
||||
|
||||
from .config import Config
|
||||
from .utils import lock_workstation, get_timestamp
|
||||
|
||||
|
||||
class Actions:
|
||||
def __init__(self, config: Config, monitor_ref=None):
|
||||
self.config = config
|
||||
self.monitor = monitor_ref # for events.log writing via monitor._log_event
|
||||
|
||||
# ---------- public entry points ----------
|
||||
|
||||
def handle_intrusion(self, reason: str, snapshot_path: Optional[str] = None,
|
||||
snapshot_paths: Optional[list] = None) -> None:
|
||||
"""Main entry point called by Monitor on intrusion.
|
||||
|
||||
Note: does NOT lock the workstation. OS lock is reserved for repeated
|
||||
failed unlock attempts and is handled by the tray unlock dialog.
|
||||
|
||||
`snapshot_paths` (when supplied by multi-cam Monitor) attaches every
|
||||
captured frame; `snapshot_path` is the legacy single-attachment path.
|
||||
"""
|
||||
subject = reason
|
||||
body = (
|
||||
"ICEYOU detected potential unauthorized access.\n\n"
|
||||
f"Reason: {reason}\n"
|
||||
f"Time: {get_timestamp()}\n\n"
|
||||
"Check attached snapshot(s) (if available) and logs."
|
||||
)
|
||||
attachments = list(snapshot_paths) if snapshot_paths else (
|
||||
[snapshot_path] if snapshot_path else []
|
||||
)
|
||||
self.send_alert(subject, body, attachments)
|
||||
|
||||
def handle_unlock_attempt(self, record: dict) -> None:
|
||||
"""Email an alert for an interactive unlock attempt (password or face).
|
||||
|
||||
Includes the entered credential text on failed password attempts so
|
||||
you can review what the intruder typed.
|
||||
|
||||
Supports both `record["snapshot"]` (single path, back-compat) and
|
||||
`record["snapshots"]` (list of paths from multi-cam capture).
|
||||
"""
|
||||
method = record.get("method", "?")
|
||||
success = bool(record.get("success", False))
|
||||
snaps = record.get("snapshots")
|
||||
if not snaps:
|
||||
snap = record.get("snapshot")
|
||||
snaps = [snap] if snap else []
|
||||
if success and method == "password":
|
||||
# Don't send/save the real owner password.
|
||||
subject = f"Unlock success (password) at {get_timestamp()}"
|
||||
body = (
|
||||
"ICEYOU was unlocked successfully with the correct password.\n"
|
||||
f"Time: {get_timestamp()}\n"
|
||||
"Snapshot attached if available."
|
||||
)
|
||||
elif success and method == "face":
|
||||
subject = (
|
||||
f"Unlock success (face: {record.get('label')}, conf={record.get('confidence')}) "
|
||||
f"at {get_timestamp()}"
|
||||
)
|
||||
body = (
|
||||
"ICEYOU was unlocked by face recognition.\n"
|
||||
f"Label: {record.get('label')}\n"
|
||||
f"Confidence: {record.get('confidence')}\n"
|
||||
f"Time: {get_timestamp()}\n"
|
||||
)
|
||||
elif method == "password":
|
||||
entered = record.get("entered", "")
|
||||
subject = f"FAILED unlock attempt (password) at {get_timestamp()}"
|
||||
body = (
|
||||
"ICEYOU recorded a FAILED password unlock attempt.\n\n"
|
||||
f"Entered: {entered!r}\n"
|
||||
f"Time: {get_timestamp()}\n\n"
|
||||
"Snapshot of the person at the keyboard is attached."
|
||||
)
|
||||
else: # failed face
|
||||
subject = f"FAILED unlock attempt (face) at {get_timestamp()}"
|
||||
body = (
|
||||
"ICEYOU recorded a FAILED face unlock attempt.\n\n"
|
||||
f"Predicted label: {record.get('label')}\n"
|
||||
f"Confidence: {record.get('confidence')}\n"
|
||||
f"Time: {get_timestamp()}\n"
|
||||
)
|
||||
self.send_alert(subject, body, snaps)
|
||||
|
||||
def send_test_email(self) -> None:
|
||||
self.send_alert(
|
||||
"Test email",
|
||||
f"This is a test from ICEYOU at {get_timestamp()}. If you see this, SMTP works.",
|
||||
None,
|
||||
)
|
||||
|
||||
# ---------- internal ----------
|
||||
|
||||
def send_alert(self, subject: str, body: str, attachments) -> None:
|
||||
"""`attachments` may be None, a single path string, or a list of paths."""
|
||||
if not self.config.get("email.enabled", False):
|
||||
return
|
||||
if attachments is None:
|
||||
paths = []
|
||||
elif isinstance(attachments, (list, tuple)):
|
||||
paths = [p for p in attachments if p]
|
||||
else:
|
||||
paths = [attachments]
|
||||
threading.Thread(
|
||||
target=self._send_email,
|
||||
args=(subject, body, paths),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _log(self, event: str, details: str) -> None:
|
||||
if self.monitor is not None and hasattr(self.monitor, "_log_event"):
|
||||
try:
|
||||
self.monitor._log_event(event, details)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[Actions] {event}: {details}")
|
||||
|
||||
def _send_email(self, subject: str, body: str, snapshot_paths) -> None:
|
||||
email_cfg = self.config.get("email", {})
|
||||
prefix = email_cfg.get("subject_prefix", "[ICEYOU Alert]")
|
||||
# Normalize attachment paths to a list
|
||||
if snapshot_paths is None:
|
||||
paths = []
|
||||
elif isinstance(snapshot_paths, (list, tuple)):
|
||||
paths = list(snapshot_paths)
|
||||
else:
|
||||
paths = [snapshot_paths]
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"{prefix} {subject}"
|
||||
msg["From"] = email_cfg.get("from_addr", "")
|
||||
msg["To"] = email_cfg.get("to_addr", "")
|
||||
msg.set_content(body)
|
||||
|
||||
for p in paths:
|
||||
if not p:
|
||||
continue
|
||||
pp = Path(p)
|
||||
if not pp.exists():
|
||||
continue
|
||||
with open(pp, "rb") as f:
|
||||
img_data = f.read()
|
||||
msg.add_attachment(
|
||||
img_data,
|
||||
maintype="image",
|
||||
subtype="jpeg",
|
||||
filename=pp.name,
|
||||
)
|
||||
|
||||
context = ssl.create_default_context()
|
||||
host = email_cfg.get("smtp_server", "smtp.gmail.com")
|
||||
port = email_cfg.get("smtp_port", 587)
|
||||
with smtplib.SMTP(host, port, timeout=20) as server:
|
||||
server.ehlo()
|
||||
if email_cfg.get("use_tls", True):
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
server.login(email_cfg.get("username", ""), email_cfg.get("password", ""))
|
||||
server.send_message(msg)
|
||||
self._log("email_sent", f"{subject} (atts={len(paths)})")
|
||||
except Exception as e:
|
||||
self._log("email_failed", f"{type(e).__name__}: {e}")
|
||||
243
src/iceyou/camera.py
Normal file
243
src/iceyou/camera.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Multi-camera manager with dead-camera detection.
|
||||
|
||||
Background
|
||||
----------
|
||||
On Windows DSHOW, `cv2.VideoCapture(idx, CAP_DSHOW)` for a phantom index
|
||||
(e.g. "OBS Virtual Camera" left registered with no provider, or an unplugged
|
||||
device) often returns `isOpened() == True` but the first `cap.read()` blocks
|
||||
forever waiting for data that will never arrive. To survive this we probe
|
||||
every requested index with a frame-read on a worker thread and a timeout;
|
||||
indices that don't deliver within `probe_timeout` are marked DEAD and
|
||||
permanently skipped.
|
||||
|
||||
Public API
|
||||
----------
|
||||
- `Camera(config)` multi-stream manager
|
||||
- `camera.read_frame()` primary stream frame (back-compat)
|
||||
- `camera.read_all_frames()` dict {index: frame}
|
||||
- `camera.capture_snapshot(reason)` single file from primary (back-compat)
|
||||
- `camera.capture_all_snapshots(reason)` one file per alive stream -> list[str]
|
||||
- `camera.is_vision_obscured()` True only if ALL alive streams are obscured
|
||||
- `camera.alive_streams` list of CameraStream (use for per-cam
|
||||
consumers such as MotionRecorder)
|
||||
- `camera.release()` close everything
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from .utils import get_timestamp
|
||||
from .config import Config
|
||||
|
||||
|
||||
class CameraStream:
|
||||
"""Single webcam wrapper that survives dead-device probing."""
|
||||
|
||||
def __init__(self, index: int):
|
||||
self.index = int(index)
|
||||
self._cap: Optional[cv2.VideoCapture] = None
|
||||
self._lock = threading.Lock()
|
||||
self.is_alive = False
|
||||
|
||||
def open(self, probe_timeout: float = 2.5) -> bool:
|
||||
"""Open the capture and verify it actually delivers a frame within
|
||||
`probe_timeout` seconds. Returns True on success, False if dead."""
|
||||
cap = cv2.VideoCapture(self.index, cv2.CAP_DSHOW)
|
||||
if not cap.isOpened():
|
||||
try:
|
||||
cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
self.is_alive = False
|
||||
return False
|
||||
|
||||
# cap.read() can block forever on phantom DSHOW devices - probe in a
|
||||
# daemon thread and bail if it doesn't return in time.
|
||||
result = {"ok": False, "frame": None, "done": False}
|
||||
|
||||
def reader():
|
||||
try:
|
||||
ok, frame = cap.read()
|
||||
result["ok"] = bool(ok and frame is not None)
|
||||
result["frame"] = frame
|
||||
except Exception:
|
||||
result["ok"] = False
|
||||
finally:
|
||||
result["done"] = True
|
||||
|
||||
t = threading.Thread(target=reader, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=probe_timeout)
|
||||
|
||||
if not result["done"] or not result["ok"]:
|
||||
# Reader still blocked OR read returned no frame: dead camera.
|
||||
# We intentionally do NOT cap.release() here because the reader
|
||||
# may still be inside cap.read() and releasing concurrently can
|
||||
# crash the DSHOW driver. The leaked cap dies at process exit.
|
||||
self.is_alive = False
|
||||
return False
|
||||
|
||||
self._cap = cap
|
||||
self.is_alive = True
|
||||
return True
|
||||
|
||||
def read_frame(self):
|
||||
with self._lock:
|
||||
if self._cap is None:
|
||||
return None
|
||||
try:
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
return None
|
||||
return frame
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def release(self) -> None:
|
||||
with self._lock:
|
||||
if self._cap is not None:
|
||||
try:
|
||||
self._cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
self._cap = None
|
||||
self.is_alive = False
|
||||
|
||||
|
||||
class Camera:
|
||||
"""Multi-camera manager. Backward compatible single-cam API + new
|
||||
multi-cam helpers."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.snapshot_dir = Path(config.get("snapshot_dir", "snapshots"))
|
||||
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve requested indices: prefer the new list, fall back to legacy single
|
||||
indices = config.get("camera_device_indices")
|
||||
if not indices:
|
||||
indices = [int(config.get("camera_device_index", 0))]
|
||||
# De-duplicate while preserving order
|
||||
seen = set()
|
||||
ordered = []
|
||||
for i in indices:
|
||||
ii = int(i)
|
||||
if ii not in seen:
|
||||
seen.add(ii)
|
||||
ordered.append(ii)
|
||||
|
||||
# Camera obstruction detection settings
|
||||
obs = config.get("camera_obscured_detection", {}) or {}
|
||||
self.obscured_enabled = bool(obs.get("enabled", True))
|
||||
self.brightness_threshold = float(obs.get("brightness_threshold", 35))
|
||||
self.variance_threshold = float(obs.get("variance_threshold", 10))
|
||||
|
||||
probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5))
|
||||
|
||||
self.streams: List[CameraStream] = []
|
||||
for idx in ordered:
|
||||
stream = CameraStream(idx)
|
||||
if stream.open(probe_timeout=probe_timeout):
|
||||
print(f"[Camera] index {idx} OPEN")
|
||||
self.streams.append(stream)
|
||||
else:
|
||||
print(f"[Camera] index {idx} DEAD (skipped after {probe_timeout}s probe)")
|
||||
# keep the dead stream object out of the list entirely
|
||||
if not self.alive_streams:
|
||||
print("[Camera] WARNING: no working cameras detected")
|
||||
|
||||
# ---- discovery / accessors ----
|
||||
|
||||
@property
|
||||
def alive_streams(self) -> List[CameraStream]:
|
||||
return [s for s in self.streams if s.is_alive]
|
||||
|
||||
@property
|
||||
def alive_indices(self) -> List[int]:
|
||||
return [s.index for s in self.alive_streams]
|
||||
|
||||
@property
|
||||
def primary(self) -> Optional[CameraStream]:
|
||||
return self.alive_streams[0] if self.alive_streams else None
|
||||
|
||||
def is_camera_available(self) -> bool:
|
||||
return self.primary is not None
|
||||
|
||||
# ---- frame I/O ----
|
||||
|
||||
def read_frame(self):
|
||||
"""Back-compat: returns a frame from the PRIMARY (first alive) camera."""
|
||||
p = self.primary
|
||||
return p.read_frame() if p else None
|
||||
|
||||
def read_all_frames(self) -> Dict[int, object]:
|
||||
"""Returns {camera_index: frame} for every alive stream (None if a read failed)."""
|
||||
return {s.index: s.read_frame() for s in self.alive_streams}
|
||||
|
||||
# ---- snapshots ----
|
||||
|
||||
def capture_snapshot(self, reason: str = "trigger") -> Optional[str]:
|
||||
"""Back-compat single snapshot from PRIMARY camera. Returns path or None."""
|
||||
p = self.primary
|
||||
if p is None:
|
||||
return None
|
||||
frame = p.read_frame()
|
||||
if frame is None:
|
||||
return None
|
||||
ts = get_timestamp()
|
||||
suffix = f"cam{p.index}_" if len(self.alive_streams) > 1 else ""
|
||||
filename = f"snapshot_{ts}_{suffix}{reason}.jpg"
|
||||
filepath = self.snapshot_dir / filename
|
||||
return str(filepath) if cv2.imwrite(str(filepath), frame) else None
|
||||
|
||||
def capture_all_snapshots(self, reason: str = "trigger") -> List[str]:
|
||||
"""Snap from every alive camera. Returns list of saved file paths."""
|
||||
out: List[str] = []
|
||||
ts = get_timestamp()
|
||||
for s in self.alive_streams:
|
||||
frame = s.read_frame()
|
||||
if frame is None:
|
||||
continue
|
||||
filename = f"snapshot_{ts}_cam{s.index}_{reason}.jpg"
|
||||
filepath = self.snapshot_dir / filename
|
||||
if cv2.imwrite(str(filepath), frame):
|
||||
out.append(str(filepath))
|
||||
return out
|
||||
|
||||
# ---- obscured detection ----
|
||||
|
||||
def is_vision_obscured(self) -> bool:
|
||||
"""True only if EVERY alive camera reports obscured vision.
|
||||
|
||||
One unblocked camera = scene is visible to ICEYOU; don't trigger lockdown.
|
||||
"""
|
||||
if not self.obscured_enabled:
|
||||
return False
|
||||
alive = self.alive_streams
|
||||
if not alive:
|
||||
return False # No cameras = can't decide; don't escalate
|
||||
obscured_all = True
|
||||
for s in alive:
|
||||
frame = s.read_frame()
|
||||
if frame is None:
|
||||
continue
|
||||
try:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
except Exception:
|
||||
continue
|
||||
mean_b = float(gray.mean())
|
||||
var = float(gray.std(ddof=0))
|
||||
if mean_b >= self.brightness_threshold and var >= self.variance_threshold:
|
||||
obscured_all = False
|
||||
break
|
||||
if obscured_all:
|
||||
print(f"[Camera] All {len(alive)} alive camera(s) report obscured")
|
||||
return obscured_all
|
||||
|
||||
# ---- lifecycle ----
|
||||
|
||||
def release(self) -> None:
|
||||
for s in self.streams:
|
||||
s.release()
|
||||
81
src/iceyou/config.py
Normal file
81
src/iceyou/config.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Configuration loader for ICEYOU."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"idle_timeout_seconds": 300,
|
||||
"monitoring_enabled": True,
|
||||
"camera_device_index": 0,
|
||||
"snapshot_on_trigger": True,
|
||||
"snapshot_dir": "snapshots",
|
||||
"log_file": "events.log",
|
||||
"email": {
|
||||
"enabled": False,
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_addr": "",
|
||||
"to_addr": "",
|
||||
"subject_prefix": "[ICEYOU Alert]"
|
||||
},
|
||||
"lock_workstation": True,
|
||||
"device_monitoring": True,
|
||||
"white_screen_on_away": True,
|
||||
"unlock_password": "iceyou2026",
|
||||
"max_unlock_attempts": 3,
|
||||
"camera_obscured_detection": {
|
||||
"enabled": True,
|
||||
"brightness_threshold": 35,
|
||||
"variance_threshold": 10,
|
||||
"check_interval_seconds": 12
|
||||
},
|
||||
"hotkeys": {
|
||||
"force_away": "<ctrl>+<alt>+l",
|
||||
"unlock": "<ctrl>+<alt>+u"
|
||||
},
|
||||
"face_recognition_enabled": False
|
||||
}
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_path: str = "config.json"):
|
||||
self.config_path = Path(config_path)
|
||||
self.data: Dict[str, Any] = DEFAULT_CONFIG.copy()
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
if self.config_path.exists():
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = json.load(f)
|
||||
# Deep merge
|
||||
self._merge(self.data, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}. Using defaults.")
|
||||
else:
|
||||
print("No config.json found. Using defaults. Copy config.example.json to config.json")
|
||||
|
||||
def _merge(self, base: Dict, update: Dict) -> None:
|
||||
for k, v in update.items():
|
||||
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
|
||||
self._merge(base[k], v)
|
||||
else:
|
||||
base[k] = v
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
keys = key.split(".")
|
||||
val = self.data
|
||||
for k in keys:
|
||||
if isinstance(val, dict) and k in val:
|
||||
val = val[k]
|
||||
else:
|
||||
return default
|
||||
return val
|
||||
|
||||
def save(self) -> None:
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
66
src/iceyou/device_monitor.py
Normal file
66
src/iceyou/device_monitor.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Device monitoring for USB/peripheral connect/disconnect events.
|
||||
|
||||
Simple implementation using drive letter polling for removable media.
|
||||
For full WM_DEVICECHANGE support, a message window + WNDPROC is needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Set, Optional
|
||||
|
||||
|
||||
class DeviceMonitor:
|
||||
def __init__(self, on_device_event: Callable[[str, str], None]):
|
||||
"""
|
||||
on_device_event: callback(event, details) e.g. ("device_added", "E:\\")
|
||||
"""
|
||||
self.on_device_event = on_device_event
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._known_drives: Set[str] = set()
|
||||
|
||||
def _get_removable_drives(self) -> Set[str]:
|
||||
drives = set()
|
||||
for letter in string.ascii_uppercase:
|
||||
drive = f"{letter}:"
|
||||
if os.path.exists(drive) and os.path.ismount(drive):
|
||||
# Heuristic: treat as removable if not C: and exists
|
||||
if letter != "C":
|
||||
drives.add(drive)
|
||||
return drives
|
||||
|
||||
def _poll(self) -> None:
|
||||
while self._running:
|
||||
current = self._get_removable_drives()
|
||||
added = current - self._known_drives
|
||||
removed = self._known_drives - current
|
||||
|
||||
for d in added:
|
||||
try:
|
||||
self.on_device_event("device_added", d)
|
||||
except Exception:
|
||||
pass
|
||||
for d in removed:
|
||||
try:
|
||||
self.on_device_event("device_removed", d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._known_drives = current
|
||||
time.sleep(5) # Poll every 5 seconds
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._known_drives = self._get_removable_drives()
|
||||
self._thread = threading.Thread(target=self._poll, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
200
src/iceyou/face_recognizer.py
Normal file
200
src/iceyou/face_recognizer.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Face recognition for ICEYOU unlock flow.
|
||||
|
||||
Loads the LBPH model trained by face_enrollment.py and verifies frames
|
||||
captured from the active camera. Designed to be safe even if the model
|
||||
or contrib opencv build is missing - methods just return False.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
import cv2
|
||||
|
||||
FACES_DIR = Path("faces")
|
||||
MODEL_FILE = FACES_DIR / "model.yml"
|
||||
LABELS_FILE = FACES_DIR / "labels.json"
|
||||
|
||||
|
||||
class FaceRecognizer:
|
||||
"""Verify a webcam frame against the enrolled LBPH model.
|
||||
|
||||
Lower LBPH confidence == better match. Typical thresholds:
|
||||
< 50 strong match
|
||||
< 70 acceptable
|
||||
> 90 poor / reject
|
||||
"""
|
||||
|
||||
def __init__(self, confidence_threshold: float = 70.0, allowed_labels: Optional[List[str]] = None, min_successful_matches: int = 2):
|
||||
self.confidence_threshold = float(confidence_threshold)
|
||||
self.allowed_labels = set(allowed_labels) if allowed_labels else None # None = any enrolled
|
||||
self.min_successful_matches = max(1, int(min_successful_matches))
|
||||
self._recognizer = None
|
||||
self._labels: dict = {}
|
||||
self._detectors: dict = {}
|
||||
self._loaded = False
|
||||
self._load()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._loaded and self._recognizer is not None
|
||||
|
||||
def _load(self) -> None:
|
||||
if not MODEL_FILE.exists() or not LABELS_FILE.exists():
|
||||
print("[FaceRecognizer] No trained model found - face unlock disabled.")
|
||||
return
|
||||
try:
|
||||
if not hasattr(cv2, "face"):
|
||||
print("[FaceRecognizer] cv2.face missing - install opencv-contrib-python.")
|
||||
return
|
||||
rec = cv2.face.LBPHFaceRecognizer_create()
|
||||
rec.read(str(MODEL_FILE))
|
||||
self._recognizer = rec
|
||||
with open(LABELS_FILE, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
self._labels = {int(k): v for k, v in raw.items()}
|
||||
self._detectors = self._load_detectors()
|
||||
self._loaded = True
|
||||
print(f"[FaceRecognizer] Loaded model with labels: {list(self._labels.values())}")
|
||||
print(f"[FaceRecognizer] Consensus requirement: {self.min_successful_matches} successful match(es) required")
|
||||
except Exception as e:
|
||||
print(f"[FaceRecognizer] Load failed: {e}")
|
||||
self._recognizer = None
|
||||
self._loaded = False
|
||||
|
||||
@staticmethod
|
||||
def _load_detectors() -> dict:
|
||||
base = Path(cv2.data.haarcascades)
|
||||
detectors = {}
|
||||
for key, name in (
|
||||
("frontal", "haarcascade_frontalface_default.xml"),
|
||||
("alt2", "haarcascade_frontalface_alt2.xml"),
|
||||
("profile", "haarcascade_profileface.xml"),
|
||||
):
|
||||
p = base / name
|
||||
if p.exists():
|
||||
detectors[key] = cv2.CascadeClassifier(str(p))
|
||||
return detectors
|
||||
|
||||
def _detect_faces(self, gray):
|
||||
if not self._detectors:
|
||||
return []
|
||||
eq = cv2.equalizeHist(gray)
|
||||
params = dict(scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))
|
||||
for key in ("frontal", "alt2", "profile"):
|
||||
det = self._detectors.get(key)
|
||||
if det is None:
|
||||
continue
|
||||
faces = det.detectMultiScale(eq, **params)
|
||||
if len(faces) > 0:
|
||||
return faces
|
||||
# mirrored profile
|
||||
det = self._detectors.get("profile")
|
||||
if det is not None:
|
||||
flipped = cv2.flip(eq, 1)
|
||||
faces = det.detectMultiScale(flipped, **params)
|
||||
if len(faces) > 0:
|
||||
w = eq.shape[1]
|
||||
return [(w - x - fw, y, fw, fh) for (x, y, fw, fh) in faces]
|
||||
return []
|
||||
|
||||
def verify_frame(self, frame) -> Tuple[bool, Optional[str], Optional[float]]:
|
||||
"""Try to recognize an authorized user in `frame`.
|
||||
|
||||
Returns (matched, label, confidence). `matched` is True only if
|
||||
confidence is below threshold AND the predicted label is in
|
||||
allowed_labels (if configured).
|
||||
"""
|
||||
if not self.available or frame is None:
|
||||
return False, None, None
|
||||
try:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
except Exception:
|
||||
return False, None, None
|
||||
faces = self._detect_faces(gray)
|
||||
if len(faces) == 0:
|
||||
return False, None, None
|
||||
|
||||
H, W = gray.shape[:2]
|
||||
best_label = None
|
||||
best_conf = None
|
||||
for (x, y, w, h) in faces:
|
||||
# Try a few crop variants - LBPH is sensitive to exact framing.
|
||||
# Enrollment saved with ~10% margin; we also try 5% and 15% to
|
||||
# tolerate small detector size differences.
|
||||
for margin_pct in (0.10, 0.05, 0.15):
|
||||
m = int(margin_pct * max(w, h))
|
||||
x0 = max(x - m, 0)
|
||||
y0 = max(y - m, 0)
|
||||
x1 = min(x + w + m, W)
|
||||
y1 = min(y + h + m, H)
|
||||
roi = gray[y0:y1, x0:x1]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
try:
|
||||
# Match enrollment exactly: raw grayscale crop resized to 200x200.
|
||||
# NO equalizeHist (enrollment didn't apply it to saved images).
|
||||
roi_resized = cv2.resize(roi, (200, 200))
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
label_id, conf = self._recognizer.predict(roi_resized)
|
||||
except Exception:
|
||||
continue
|
||||
if best_conf is None or conf < best_conf:
|
||||
best_conf = conf
|
||||
best_label = self._labels.get(label_id)
|
||||
|
||||
if best_label is None or best_conf is None:
|
||||
return False, None, None
|
||||
if best_conf > self.confidence_threshold:
|
||||
return False, best_label, best_conf
|
||||
if self.allowed_labels is not None and best_label not in self.allowed_labels:
|
||||
return False, best_label, best_conf
|
||||
return True, best_label, best_conf
|
||||
|
||||
def verify_from_camera(self, camera, attempts: int = 3) -> Tuple[bool, Optional[str], Optional[float]]:
|
||||
"""Grab up to `attempts` frames across all alive cameras.
|
||||
|
||||
Requires at least `self.min_successful_matches` successful verifications
|
||||
(on allowed labels) before returning True. This greatly reduces the
|
||||
chance of a look-alike (child, sibling, etc.) triggering an unlock from
|
||||
a single lucky frame.
|
||||
|
||||
Multi-cam aware via alive_streams.
|
||||
"""
|
||||
if not self.available:
|
||||
return False, None, None
|
||||
sources = []
|
||||
alive = getattr(camera, "alive_streams", None)
|
||||
if alive:
|
||||
sources = list(alive)
|
||||
else:
|
||||
sources = [camera]
|
||||
|
||||
successes = 0
|
||||
matched_label = None
|
||||
matched_conf = None
|
||||
best = (False, None, None) # best non-success for reporting
|
||||
|
||||
for _ in range(max(1, attempts)):
|
||||
for src in sources:
|
||||
try:
|
||||
frame = src.read_frame()
|
||||
except Exception:
|
||||
continue
|
||||
if frame is None:
|
||||
continue
|
||||
ok, label, conf = self.verify_frame(frame)
|
||||
if ok and label is not None:
|
||||
successes += 1
|
||||
matched_label = label
|
||||
matched_conf = conf
|
||||
if successes >= self.min_successful_matches:
|
||||
return True, matched_label, matched_conf
|
||||
else:
|
||||
if conf is not None and (best[2] is None or conf < best[2]):
|
||||
best = (False, label, conf)
|
||||
|
||||
# Not enough consensus reached
|
||||
return best
|
||||
65
src/iceyou/input_hooks.py
Normal file
65
src/iceyou/input_hooks.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Global keyboard and mouse hooks using pynput."""
|
||||
|
||||
from pynput import keyboard, mouse
|
||||
from typing import Callable, Optional
|
||||
import threading
|
||||
|
||||
|
||||
class InputHooks:
|
||||
def __init__(self, on_activity: Callable[[str], None]):
|
||||
"""
|
||||
on_activity: callback(event_type) e.g. "keyboard", "mouse_move", "mouse_click"
|
||||
"""
|
||||
self.on_activity = on_activity
|
||||
self._keyboard_listener: Optional[keyboard.Listener] = None
|
||||
self._mouse_listener: Optional[mouse.Listener] = None
|
||||
self._running = False
|
||||
|
||||
def _on_key(self, key):
|
||||
if self._running:
|
||||
try:
|
||||
self.on_activity("keyboard")
|
||||
except Exception:
|
||||
pass
|
||||
# Do not suppress - let keys pass through
|
||||
|
||||
def _on_mouse_move(self, x, y):
|
||||
if self._running:
|
||||
try:
|
||||
self.on_activity("mouse_move")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_mouse_click(self, x, y, button, pressed):
|
||||
if self._running and pressed:
|
||||
try:
|
||||
self.on_activity("mouse_click")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._keyboard_listener = keyboard.Listener(on_press=self._on_key)
|
||||
self._mouse_listener = mouse.Listener(
|
||||
on_move=self._on_mouse_move,
|
||||
on_click=self._on_mouse_click
|
||||
)
|
||||
self._keyboard_listener.start()
|
||||
self._mouse_listener.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._keyboard_listener:
|
||||
self._keyboard_listener.stop()
|
||||
if self._mouse_listener:
|
||||
self._mouse_listener.stop()
|
||||
self._keyboard_listener = None
|
||||
self._mouse_listener = None
|
||||
|
||||
def pause(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def resume(self) -> None:
|
||||
self._running = True
|
||||
156
src/iceyou/keyboard_block.py
Normal file
156
src/iceyou/keyboard_block.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Low-level Windows keyboard hook to block escape combos while ICEYOU is locked.
|
||||
|
||||
Blocks (when active):
|
||||
- WIN keys (left/right) -> Start menu / Win+anything
|
||||
- CTRL+ESC -> Start menu
|
||||
- ALT+TAB, ALT+ESC -> task switching
|
||||
- ALT+F4 -> close window
|
||||
- ALT+SPACE -> window menu (Restore/Move/Close)
|
||||
- CTRL+SHIFT+ESC -> Task Manager hotkey
|
||||
|
||||
CANNOT block (by Windows design):
|
||||
- CTRL+ALT+DEL (Secure Attention Sequence)
|
||||
- Hard reset / power button
|
||||
These require Group Policy or admin / kernel privilege.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import threading
|
||||
from ctypes import wintypes
|
||||
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
WH_KEYBOARD_LL = 13
|
||||
WM_KEYDOWN = 0x0100
|
||||
WM_SYSKEYDOWN = 0x0104
|
||||
WM_QUIT = 0x0012
|
||||
|
||||
VK_LWIN = 0x5B
|
||||
VK_RWIN = 0x5C
|
||||
VK_TAB = 0x09
|
||||
VK_ESCAPE = 0x1B
|
||||
VK_F4 = 0x73
|
||||
VK_SPACE = 0x20
|
||||
VK_MENU = 0x12 # ALT
|
||||
VK_CONTROL = 0x11
|
||||
VK_SHIFT = 0x10
|
||||
|
||||
LRESULT = ctypes.c_long
|
||||
ULONG_PTR = ctypes.c_size_t
|
||||
|
||||
|
||||
class KBDLLHOOKSTRUCT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("vkCode", wintypes.DWORD),
|
||||
("scanCode", wintypes.DWORD),
|
||||
("flags", wintypes.DWORD),
|
||||
("time", wintypes.DWORD),
|
||||
("dwExtraInfo", ULONG_PTR),
|
||||
]
|
||||
|
||||
|
||||
HOOKPROC = ctypes.WINFUNCTYPE(LRESULT, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM)
|
||||
|
||||
user32.SetWindowsHookExW.restype = wintypes.HHOOK
|
||||
user32.SetWindowsHookExW.argtypes = [ctypes.c_int, HOOKPROC, wintypes.HINSTANCE, wintypes.DWORD]
|
||||
user32.UnhookWindowsHookEx.restype = wintypes.BOOL
|
||||
user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
|
||||
user32.CallNextHookEx.restype = LRESULT
|
||||
user32.CallNextHookEx.argtypes = [wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM]
|
||||
user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT]
|
||||
user32.GetMessageW.restype = ctypes.c_int
|
||||
user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)]
|
||||
user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)]
|
||||
user32.PostThreadMessageW.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
||||
user32.PostThreadMessageW.restype = wintypes.BOOL
|
||||
user32.GetAsyncKeyState.argtypes = [ctypes.c_int]
|
||||
user32.GetAsyncKeyState.restype = ctypes.c_short
|
||||
kernel32.GetCurrentThreadId.restype = wintypes.DWORD
|
||||
|
||||
|
||||
class KeyboardBlocker:
|
||||
"""Installs a WH_KEYBOARD_LL hook on its own thread with a message pump."""
|
||||
|
||||
def __init__(self):
|
||||
self._hook = None
|
||||
self._thread: threading.Thread = None
|
||||
self._thread_id: int = 0
|
||||
self._active = False
|
||||
self._hook_proc = None # keep ref so GC doesn't free callback
|
||||
self._ready = threading.Event()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
def start(self) -> None:
|
||||
if self._active:
|
||||
return
|
||||
self._ready.clear()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
self._ready.wait(timeout=2.0)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
self._active = False
|
||||
if self._thread_id:
|
||||
user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._thread_id = 0
|
||||
|
||||
@staticmethod
|
||||
def _is_pressed(vk: int) -> bool:
|
||||
return (user32.GetAsyncKeyState(vk) & 0x8000) != 0
|
||||
|
||||
def _hook_callback(self, nCode, wParam, lParam):
|
||||
try:
|
||||
if nCode == 0 and wParam in (WM_KEYDOWN, WM_SYSKEYDOWN):
|
||||
kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT))[0]
|
||||
vk = kb.vkCode
|
||||
|
||||
if vk in (VK_LWIN, VK_RWIN):
|
||||
return 1
|
||||
|
||||
alt = self._is_pressed(VK_MENU)
|
||||
ctrl = self._is_pressed(VK_CONTROL)
|
||||
|
||||
if alt and vk in (VK_TAB, VK_ESCAPE, VK_F4, VK_SPACE):
|
||||
return 1
|
||||
if ctrl and vk == VK_ESCAPE:
|
||||
return 1
|
||||
except Exception:
|
||||
pass
|
||||
return user32.CallNextHookEx(self._hook, nCode, wParam, lParam)
|
||||
|
||||
def _run(self) -> None:
|
||||
self._thread_id = kernel32.GetCurrentThreadId()
|
||||
self._hook_proc = HOOKPROC(self._hook_callback)
|
||||
self._hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, self._hook_proc, None, 0)
|
||||
if not self._hook:
|
||||
err = ctypes.get_last_error()
|
||||
print(f"[KeyboardBlocker] SetWindowsHookEx failed (error {err})")
|
||||
self._active = False
|
||||
self._ready.set()
|
||||
return
|
||||
self._active = True
|
||||
self._ready.set()
|
||||
|
||||
msg = wintypes.MSG()
|
||||
try:
|
||||
while True:
|
||||
rc = user32.GetMessageW(ctypes.byref(msg), None, 0, 0)
|
||||
if rc <= 0: # WM_QUIT (0) or error (-1)
|
||||
break
|
||||
user32.TranslateMessage(ctypes.byref(msg))
|
||||
user32.DispatchMessageW(ctypes.byref(msg))
|
||||
finally:
|
||||
if self._hook:
|
||||
user32.UnhookWindowsHookEx(self._hook)
|
||||
self._hook = None
|
||||
self._active = False
|
||||
195
src/iceyou/monitor.py
Normal file
195
src/iceyou/monitor.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Core Monitor orchestrator: manages away state, hooks, camera, device monitor, and triggers actions."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from queue import Queue, Empty
|
||||
|
||||
from .config import Config
|
||||
from .utils import get_idle_time_seconds, lock_workstation, get_timestamp, format_event
|
||||
from .camera import Camera
|
||||
from .input_hooks import InputHooks
|
||||
from .device_monitor import DeviceMonitor
|
||||
from .white_screen import WhiteScreen
|
||||
|
||||
|
||||
class Monitor:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.idle_timeout = config.get("idle_timeout_seconds", 300)
|
||||
self.is_away = False
|
||||
self.manual_lock = False # True when away was forced (manual / intrusion); only unlock clears
|
||||
self.last_trigger_time = 0.0
|
||||
self.cooldown_seconds = 30 # Avoid spam triggers
|
||||
self.snooze_until = 0.0 # When > now, skip auto-away based on idle
|
||||
|
||||
self.event_queue: Queue = Queue()
|
||||
self._running = False
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
|
||||
self.camera = Camera(config)
|
||||
self.input_hooks = InputHooks(on_activity=self._on_input_activity)
|
||||
self.device_monitor = DeviceMonitor(on_device_event=self._on_device_event)
|
||||
|
||||
# WhiteScreen wired by TrayApp with verify_password/on_failed/on_unlocked callbacks
|
||||
dim_cfg = config.get("white_screen_dimming", {}) or {}
|
||||
self.white_screen = WhiteScreen(
|
||||
enabled=config.get("white_screen_on_away", True),
|
||||
max_attempts=config.get("max_unlock_attempts", 3),
|
||||
block_escape_keys=config.get("block_escape_keys", True),
|
||||
dimming_enabled=bool(dim_cfg.get("enabled", True)),
|
||||
dimming_duration_seconds=float(dim_cfg.get("duration_seconds", 60.0)),
|
||||
target_grey=dim_cfg.get("target_grey", "#1a1a1a"),
|
||||
)
|
||||
|
||||
self.log_file = Path(config.get("log_file", "events.log"))
|
||||
|
||||
# Actions will be injected later
|
||||
self.on_intrusion: Optional[callable] = None
|
||||
|
||||
def _log_event(self, event_type: str, details: str) -> None:
|
||||
event = {
|
||||
"timestamp": get_timestamp(),
|
||||
"type": event_type,
|
||||
"details": details,
|
||||
"away": self.is_away
|
||||
}
|
||||
try:
|
||||
with open(self.log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
print(format_event(event_type, details))
|
||||
|
||||
def _on_input_activity(self, activity_type: str) -> None:
|
||||
if not self.is_away:
|
||||
return
|
||||
# If the white screen / password gate is already up, this input is the
|
||||
# challenge response (owner typing the unlock phrase). Do NOT re-trigger
|
||||
# intrusion - that wipes the password field and spams alerts.
|
||||
if self.white_screen.is_shown:
|
||||
return
|
||||
now = time.time()
|
||||
if now - self.last_trigger_time < self.cooldown_seconds:
|
||||
return
|
||||
self.last_trigger_time = now
|
||||
self._log_event("input_activity", activity_type)
|
||||
self._trigger_intrusion(f"Input activity detected: {activity_type}")
|
||||
|
||||
def _on_device_event(self, event: str, details: str) -> None:
|
||||
self._log_event(event, details)
|
||||
if self.is_away:
|
||||
self._trigger_intrusion(f"Device event: {event} {details}")
|
||||
|
||||
def _trigger_intrusion(self, reason: str) -> None:
|
||||
if not self.config.get("monitoring_enabled", True):
|
||||
return
|
||||
self._log_event("intrusion_detected", reason)
|
||||
|
||||
# Intrusion locks the gate - only correct password can clear it
|
||||
self.manual_lock = True
|
||||
self.is_away = True
|
||||
|
||||
# Capture snapshot(s) from every working camera
|
||||
snapshot_path = None
|
||||
snapshot_paths = []
|
||||
if self.config.get("snapshot_on_trigger", True):
|
||||
snapshot_paths = self.camera.capture_all_snapshots(reason="intrusion") or []
|
||||
if snapshot_paths:
|
||||
snapshot_path = snapshot_paths[0]
|
||||
self._log_event(
|
||||
"snapshot_captured",
|
||||
json.dumps(snapshot_paths, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# Call external handler (actions)
|
||||
if self.on_intrusion:
|
||||
try:
|
||||
self.on_intrusion(reason, snapshot_path, snapshot_paths)
|
||||
except TypeError:
|
||||
# back-compat: handler with old (reason, snapshot_path) signature
|
||||
try:
|
||||
self.on_intrusion(reason, snapshot_path)
|
||||
except Exception as e:
|
||||
self._log_event("action_error", str(e))
|
||||
except Exception as e:
|
||||
self._log_event("action_error", str(e))
|
||||
|
||||
def _state_machine(self) -> None:
|
||||
"""Background thread: update away state based on idle time + camera obstruction checks."""
|
||||
last_obscured_check = 0.0
|
||||
obs_cfg = self.config.get("camera_obscured_detection", {})
|
||||
check_interval = obs_cfg.get("check_interval_seconds", 12)
|
||||
|
||||
while self._running:
|
||||
idle = get_idle_time_seconds()
|
||||
now = time.time()
|
||||
snoozed = now < self.snooze_until
|
||||
should_be_away = (idle >= self.idle_timeout) and not snoozed
|
||||
|
||||
if should_be_away and not self.is_away:
|
||||
self.is_away = True
|
||||
self._log_event("state_change", f"User away (idle {idle:.1f}s)")
|
||||
if self.white_screen.enabled:
|
||||
self.white_screen.show()
|
||||
elif not should_be_away and self.is_away and not snoozed and not self.manual_lock:
|
||||
# Only auto-leave away if there was actual recent activity AND we are not in a manual/intrusion lock
|
||||
if idle < self.idle_timeout:
|
||||
self.is_away = False
|
||||
self._log_event("state_change", "User returned")
|
||||
if self.white_screen.enabled:
|
||||
self.white_screen.hide()
|
||||
|
||||
# Camera obstruction detection (only when away + white screen active)
|
||||
if (self.is_away and
|
||||
self.white_screen.enabled and
|
||||
self.white_screen.is_shown and
|
||||
self.camera.obscured_enabled):
|
||||
now = time.time()
|
||||
if now - last_obscured_check >= check_interval:
|
||||
last_obscured_check = now
|
||||
if self.camera.is_vision_obscured():
|
||||
self._trigger_intrusion("Camera vision obscured (possible tampering)")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self.input_hooks.start()
|
||||
if self.config.get("device_monitoring", True):
|
||||
self.device_monitor.start()
|
||||
self._monitor_thread = threading.Thread(target=self._state_machine, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
self._log_event("monitor_started", "ICEYOU monitoring active")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self.input_hooks.stop()
|
||||
self.device_monitor.stop()
|
||||
self.camera.release()
|
||||
if self.white_screen:
|
||||
self.white_screen.hide()
|
||||
if self._monitor_thread:
|
||||
self._monitor_thread.join(timeout=3)
|
||||
self._log_event("monitor_stopped", "Monitoring stopped")
|
||||
|
||||
def set_away(self, away: bool) -> None:
|
||||
self.is_away = away
|
||||
self._log_event("manual_state", f"Manually set away={away}")
|
||||
if away:
|
||||
# Manual force: lock until explicit unlock
|
||||
self.manual_lock = True
|
||||
self.snooze_until = 0.0
|
||||
if self.white_screen.enabled:
|
||||
self.white_screen.show()
|
||||
else:
|
||||
# Manual release (e.g. successful unlock): clear lock + snooze auto-away
|
||||
self.manual_lock = False
|
||||
self.snooze_until = time.time() + max(self.idle_timeout, 60)
|
||||
self.last_trigger_time = time.time()
|
||||
if self.white_screen.enabled:
|
||||
self.white_screen.hide()
|
||||
179
src/iceyou/motion_recorder.py
Normal file
179
src/iceyou/motion_recorder.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Motion detection + clip recording for the ICEYOU whiteout.
|
||||
|
||||
Runs only while the whiteout is shown. Continuously samples webcam frames
|
||||
into a rolling pre-buffer (default 5s). On motion: dumps the pre-buffer to
|
||||
an AVI clip, then keeps recording for `post_seconds` more. Fires `on_event`
|
||||
with the resulting clip path when finalized.
|
||||
|
||||
Uses simple frame-differencing for motion detection - cheap and effective
|
||||
in a controlled indoor scene.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cv2
|
||||
|
||||
from .utils import get_timestamp
|
||||
|
||||
|
||||
class MotionRecorder:
|
||||
def __init__(
|
||||
self,
|
||||
camera, # anything with read_frame() - Camera OR CameraStream
|
||||
clips_dir: str = "motion_clips",
|
||||
fps: int = 15,
|
||||
pre_seconds: float = 5.0,
|
||||
post_seconds: float = 5.0,
|
||||
sensitivity: int = 2500, # min motion contour area (pixels)
|
||||
cooldown_seconds: float = 8.0,
|
||||
on_event: Optional[Callable[[str], None]] = None,
|
||||
cam_label: str = "", # appended to clip filename (e.g. "cam1") for multi-cam
|
||||
):
|
||||
self.camera = camera
|
||||
self.cam_label = str(cam_label or "").strip()
|
||||
self.clips_dir = Path(clips_dir)
|
||||
self.clips_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.fps = max(5, int(fps))
|
||||
self.pre_seconds = max(0.5, float(pre_seconds))
|
||||
self.post_seconds = max(0.5, float(post_seconds))
|
||||
self.sensitivity = int(sensitivity)
|
||||
self.cooldown_seconds = float(cooldown_seconds)
|
||||
self.on_event = on_event or (lambda path: None)
|
||||
|
||||
self._buffer = deque(maxlen=int(self.fps * self.pre_seconds))
|
||||
self._prev_gray = None
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
self._writer = None
|
||||
self._writer_path: Optional[Path] = None
|
||||
self._recording_until = 0.0
|
||||
self._last_event_time = 0.0
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._buffer.clear()
|
||||
self._prev_gray = None
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._finalize_writer() # ensure any open clip is closed cleanly
|
||||
self._buffer.clear()
|
||||
self._prev_gray = None
|
||||
|
||||
# ---- internal ----
|
||||
|
||||
def _detect_motion(self, gray) -> bool:
|
||||
if self._prev_gray is None or self._prev_gray.shape != gray.shape:
|
||||
self._prev_gray = gray
|
||||
return False
|
||||
diff = cv2.absdiff(self._prev_gray, gray)
|
||||
self._prev_gray = gray
|
||||
_, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
|
||||
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
for c in contours:
|
||||
if cv2.contourArea(c) >= self.sensitivity:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _begin_recording(self, sample_frame) -> None:
|
||||
if self._writer is not None:
|
||||
return
|
||||
h, w = sample_frame.shape[:2]
|
||||
suffix = f"_{self.cam_label}" if self.cam_label else ""
|
||||
path = self.clips_dir / f"motion_{get_timestamp()}{suffix}.avi"
|
||||
fourcc = cv2.VideoWriter_fourcc(*"MJPG")
|
||||
writer = cv2.VideoWriter(str(path), fourcc, self.fps, (w, h))
|
||||
if not writer.isOpened():
|
||||
print("[MotionRecorder] VideoWriter failed to open")
|
||||
return
|
||||
self._writer = writer
|
||||
self._writer_path = path
|
||||
# Dump pre-event buffer first so the clip starts BEFORE the motion
|
||||
for buffered in list(self._buffer):
|
||||
try:
|
||||
self._writer.write(buffered)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _finalize_writer(self) -> None:
|
||||
if self._writer is None:
|
||||
return
|
||||
try:
|
||||
self._writer.release()
|
||||
except Exception:
|
||||
pass
|
||||
path = self._writer_path
|
||||
self._writer = None
|
||||
self._writer_path = None
|
||||
self._recording_until = 0.0
|
||||
if path is not None:
|
||||
try:
|
||||
self.on_event(str(path))
|
||||
except Exception as e:
|
||||
print(f"[MotionRecorder] on_event error: {e}")
|
||||
|
||||
def _loop(self) -> None:
|
||||
period = 1.0 / self.fps
|
||||
# Small warm-up: grab a few frames before motion detection kicks in
|
||||
# so the previous-frame baseline isn't blank.
|
||||
warmup_until = time.time() + 0.7
|
||||
|
||||
while self._running:
|
||||
t0 = time.time()
|
||||
frame = self.camera.read_frame()
|
||||
if frame is None:
|
||||
time.sleep(period)
|
||||
continue
|
||||
|
||||
self._buffer.append(frame)
|
||||
|
||||
try:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
||||
except Exception:
|
||||
gray = None
|
||||
|
||||
now = time.time()
|
||||
|
||||
# If currently recording, keep writing post-event frames
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.write(frame)
|
||||
except Exception:
|
||||
pass
|
||||
if now >= self._recording_until:
|
||||
self._finalize_writer()
|
||||
|
||||
# Motion detection (skip during warmup + during active cooldown)
|
||||
if gray is not None and now > warmup_until:
|
||||
moved = self._detect_motion(gray)
|
||||
if moved:
|
||||
if now - self._last_event_time >= self.cooldown_seconds:
|
||||
self._last_event_time = now
|
||||
self._begin_recording(frame)
|
||||
# Extend post-roll while motion continues
|
||||
if self._writer is not None:
|
||||
self._recording_until = now + self.post_seconds
|
||||
|
||||
elapsed = time.time() - t0
|
||||
if elapsed < period:
|
||||
time.sleep(period - elapsed)
|
||||
50
src/iceyou/startup.py
Normal file
50
src/iceyou/startup.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Helper to register ICEYOU to run at Windows startup."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import winreg
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def add_to_startup(app_name: str = "ICEYOU") -> bool:
|
||||
"""Add the current script to Windows startup via registry."""
|
||||
try:
|
||||
exe_path = sys.executable
|
||||
script_path = str(Path(__file__).parent.parent.parent / "main.py")
|
||||
command = f'"{exe_path}" "{script_path}"'
|
||||
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0, winreg.KEY_SET_VALUE
|
||||
)
|
||||
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, command)
|
||||
winreg.CloseKey(key)
|
||||
print(f"Added to startup: {command}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to add to startup: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_from_startup(app_name: str = "ICEYOU") -> bool:
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0, winreg.KEY_SET_VALUE
|
||||
)
|
||||
winreg.DeleteValue(key, app_name)
|
||||
winreg.CloseKey(key)
|
||||
print("Removed from startup.")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print("Not registered.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_to_startup()
|
||||
368
src/iceyou/tray_app.py
Normal file
368
src/iceyou/tray_app.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"""System tray application for ICEYOU using pystray + Pillow."""
|
||||
|
||||
import threading
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pystray import Icon, Menu, MenuItem
|
||||
from PIL import Image, ImageDraw
|
||||
from pynput import keyboard
|
||||
|
||||
from .config import Config
|
||||
from .monitor import Monitor
|
||||
from .actions import Actions
|
||||
from .utils import lock_workstation
|
||||
from .face_recognizer import FaceRecognizer
|
||||
from .motion_recorder import MotionRecorder
|
||||
|
||||
|
||||
def create_icon_image() -> Image.Image:
|
||||
"""Create a simple tray icon (red shield)."""
|
||||
img = Image.new("RGB", (64, 64), color=(30, 30, 30))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.polygon([(32, 4), (60, 16), (60, 40), (32, 60), (4, 40), (4, 16)], fill=(220, 53, 69))
|
||||
draw.ellipse([22, 18, 42, 38], fill=(255, 255, 255))
|
||||
draw.rectangle([28, 24, 36, 34], fill=(220, 53, 69))
|
||||
return img
|
||||
|
||||
|
||||
class TrayApp:
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
self.monitor = Monitor(self.config)
|
||||
self.actions = Actions(self.config, self.monitor)
|
||||
self.monitor.on_intrusion = self._on_intrusion
|
||||
|
||||
# Optional face recognition
|
||||
self.face_recognizer: Optional[FaceRecognizer] = None
|
||||
face_cfg = self.config.get("face_unlock", {}) or {}
|
||||
face_enabled = bool(face_cfg.get("enabled", self.config.get("face_recognition_enabled", False)))
|
||||
if face_enabled:
|
||||
threshold = float(face_cfg.get("confidence_threshold", 70.0))
|
||||
allowed = face_cfg.get("allowed_labels") # None = any enrolled
|
||||
min_matches = int(face_cfg.get("min_successful_matches", 2))
|
||||
self.face_recognizer = FaceRecognizer(
|
||||
confidence_threshold=threshold,
|
||||
allowed_labels=allowed,
|
||||
min_successful_matches=min_matches,
|
||||
)
|
||||
if not self.face_recognizer.available:
|
||||
self.face_recognizer = None # Enrol first; fall back to password only
|
||||
|
||||
# Wire WhiteScreen callbacks
|
||||
ws = self.monitor.white_screen
|
||||
ws.verify_password = self._verify_password
|
||||
ws.on_failed_attempt = self._on_failed_attempt
|
||||
ws.on_unlocked = self._on_unlocked
|
||||
ws.on_attempt = self._on_unlock_attempt
|
||||
ws.max_attempts = self.config.get("max_unlock_attempts", 3)
|
||||
if self.face_recognizer is not None:
|
||||
ws.face_unlock = self._try_face_unlock
|
||||
ws.face_unlock_auto = bool(face_cfg.get("auto_attempt", True))
|
||||
ws.face_unlock_interval = float(face_cfg.get("attempt_interval_seconds", 4.0))
|
||||
|
||||
# Optional motion recorder (only active while whiteout shown).
|
||||
# Multi-cam: build one MotionRecorder per alive stream so each camera
|
||||
# gets its own clip file.
|
||||
motion_cfg = self.config.get("motion_recording", {}) or {}
|
||||
self.motion_clips_dir = motion_cfg.get("clips_dir", "motion_clips")
|
||||
if bool(motion_cfg.get("enabled", True)):
|
||||
recorders = []
|
||||
alive = self.monitor.camera.alive_streams
|
||||
multi = len(alive) > 1
|
||||
for stream in alive:
|
||||
label = f"cam{stream.index}" if multi else ""
|
||||
recorders.append(MotionRecorder(
|
||||
camera=stream,
|
||||
clips_dir=self.motion_clips_dir,
|
||||
fps=int(motion_cfg.get("fps", 15)),
|
||||
pre_seconds=float(motion_cfg.get("pre_seconds", 5.0)),
|
||||
post_seconds=float(motion_cfg.get("post_seconds", 5.0)),
|
||||
sensitivity=int(motion_cfg.get("sensitivity", 2500)),
|
||||
cooldown_seconds=float(motion_cfg.get("cooldown_seconds", 8.0)),
|
||||
on_event=self._on_motion_event,
|
||||
cam_label=label,
|
||||
))
|
||||
ws.motion_recorders = recorders
|
||||
|
||||
self.icon: Icon = None
|
||||
self._hotkey_listener: Optional[keyboard.GlobalHotKeys] = None
|
||||
self._create_icon()
|
||||
self._setup_hotkeys()
|
||||
|
||||
# ---- WhiteScreen callbacks ----
|
||||
|
||||
def _verify_password(self, entered: str) -> bool:
|
||||
correct = str(self.config.get("unlock_password", "iceyou2026")).strip()
|
||||
return entered.strip() == correct
|
||||
|
||||
def _try_face_unlock(self):
|
||||
"""Called by WhiteScreen on its background thread. Returns (matched, label, confidence)."""
|
||||
if self.face_recognizer is None or not self.face_recognizer.available:
|
||||
return False, None, None
|
||||
attempts = int(self.config.get("face_unlock", {}).get("frames_per_attempt", 4))
|
||||
return self.face_recognizer.verify_from_camera(self.monitor.camera, attempts=attempts)
|
||||
|
||||
def _on_motion_event(self, clip_path: str) -> None:
|
||||
"""Called by MotionRecorder after a clip is finalized.
|
||||
Also resets white-screen brightness tuner on motion.
|
||||
"""
|
||||
try:
|
||||
self.monitor._log_event("motion_recorded", clip_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.icon:
|
||||
self.icon.notify(
|
||||
f"Motion event saved: {Path(clip_path).name}",
|
||||
"ICEYOU",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Reset dimmer on any detected motion while locked
|
||||
try:
|
||||
ws = self.monitor.white_screen
|
||||
if ws.is_shown:
|
||||
ws.reset_brightness()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_unlock_attempt(self, record: dict) -> None:
|
||||
"""Fired by WhiteScreen after each interactive unlock attempt.
|
||||
|
||||
Captures a webcam snapshot of whoever is at the keyboard, and logs the
|
||||
attempt (including the entered text on failures) to events.log.
|
||||
"""
|
||||
# Run on a background thread so the Tk UI thread isn't blocked by
|
||||
# camera I/O.
|
||||
def worker():
|
||||
try:
|
||||
method = record.get("method", "?")
|
||||
success = bool(record.get("success", False))
|
||||
reason = f"unlock_{'success' if success else 'fail'}_{method}"
|
||||
# Snap every working camera for richer evidence
|
||||
snaps = self.monitor.camera.capture_all_snapshots(reason=reason)
|
||||
primary = snaps[0] if snaps else None
|
||||
entry = {
|
||||
"event": "unlock_attempt",
|
||||
"method": method,
|
||||
"success": success,
|
||||
"snapshot": primary,
|
||||
"snapshots": snaps,
|
||||
}
|
||||
if method == "password" and not success:
|
||||
entry["entered"] = record.get("entered", "")
|
||||
if method == "face":
|
||||
entry["label"] = record.get("label")
|
||||
entry["confidence"] = record.get("confidence")
|
||||
self.monitor._log_event("unlock_attempt", json.dumps(entry, ensure_ascii=False))
|
||||
|
||||
# Fire email alert with the entered credentials + all snapshots
|
||||
self.actions.handle_unlock_attempt({
|
||||
**record,
|
||||
"snapshot": primary,
|
||||
"snapshots": snaps,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[TrayApp] unlock attempt logging failed: {e}")
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_failed_attempt(self, remaining: int) -> None:
|
||||
if remaining == 0:
|
||||
try:
|
||||
lock_workstation()
|
||||
except Exception as e:
|
||||
print(f"Lock failed: {e}")
|
||||
|
||||
def _on_unlocked(self) -> None:
|
||||
self.monitor.set_away(False)
|
||||
try:
|
||||
if self.icon:
|
||||
self.icon.notify("Whiteout unlocked", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Intrusion handler chained from Monitor ----
|
||||
|
||||
def _on_intrusion(self, reason: str, snapshot_path: Optional[str] = None,
|
||||
snapshot_paths: Optional[list] = None) -> None:
|
||||
"""Email + log + ensure white screen with password gate is showing."""
|
||||
self.actions.handle_intrusion(reason, snapshot_path, snapshot_paths)
|
||||
self.monitor.white_screen.enabled = True
|
||||
self.monitor.white_screen.show()
|
||||
|
||||
# ---- Tray UI ----
|
||||
|
||||
def _create_icon(self) -> None:
|
||||
menu = Menu(
|
||||
MenuItem("Start Monitoring", self._start_monitoring, default=True),
|
||||
MenuItem("Stop Monitoring", self._stop_monitoring),
|
||||
MenuItem("Toggle Away Mode", self._toggle_away),
|
||||
MenuItem("Toggle White Screen", self._toggle_white_screen),
|
||||
Menu.SEPARATOR,
|
||||
MenuItem("Open Snapshots Folder", self._open_snapshots),
|
||||
MenuItem("View Motion Clips", self._open_motion_clips),
|
||||
MenuItem("Open Log File", self._open_log),
|
||||
MenuItem("Send Test Email", self._send_test_email),
|
||||
MenuItem("Settings", self._open_settings),
|
||||
Menu.SEPARATOR,
|
||||
MenuItem("Exit", self._exit_app)
|
||||
)
|
||||
self.icon = Icon(
|
||||
"ICEYOU",
|
||||
create_icon_image(),
|
||||
"ICEYOU - Personal Monitor",
|
||||
menu
|
||||
)
|
||||
|
||||
def _start_monitoring(self, icon=None, item=None) -> None:
|
||||
self.monitor.start()
|
||||
self.icon.notify("Monitoring started", "ICEYOU")
|
||||
|
||||
def _stop_monitoring(self, icon=None, item=None) -> None:
|
||||
self.monitor.stop()
|
||||
self.icon.notify("Monitoring stopped", "ICEYOU")
|
||||
|
||||
def _toggle_away(self, icon=None, item=None) -> None:
|
||||
new_state = not self.monitor.is_away
|
||||
self.monitor.set_away(new_state)
|
||||
state = "away" if new_state else "present"
|
||||
self.icon.notify(f"Manually set to {state}", "ICEYOU")
|
||||
|
||||
def _toggle_white_screen(self, icon=None, item=None) -> None:
|
||||
ws = self.monitor.white_screen
|
||||
ws.enabled = not ws.enabled
|
||||
if not ws.enabled:
|
||||
ws.hide()
|
||||
self.icon.notify(f"White screen {'enabled' if ws.enabled else 'disabled'}", "ICEYOU")
|
||||
|
||||
# ---- Hotkeys ----
|
||||
|
||||
def _setup_hotkeys(self) -> None:
|
||||
hotkeys = self.config.get("hotkeys", {})
|
||||
force_away_combo = hotkeys.get("force_away", "<ctrl>+<alt>+l")
|
||||
unlock_combo = hotkeys.get("unlock", "<ctrl>+<alt>+u")
|
||||
|
||||
def on_force_away():
|
||||
self.monitor.set_away(True)
|
||||
try:
|
||||
self.icon.notify("Forced away mode + white screen", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_unlock():
|
||||
# Just ensure the white screen / password prompt is showing
|
||||
if self.monitor.is_away or self.monitor.white_screen.is_shown:
|
||||
self.monitor.white_screen.enabled = True
|
||||
self.monitor.white_screen.show()
|
||||
|
||||
try:
|
||||
self._hotkey_listener = keyboard.GlobalHotKeys({
|
||||
force_away_combo: on_force_away,
|
||||
unlock_combo: on_unlock,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Hotkey registration failed (check combo syntax): {e}")
|
||||
|
||||
# ---- Tray menu actions ----
|
||||
|
||||
def _open_snapshots(self, icon=None, item=None) -> None:
|
||||
path = Path(self.config.get("snapshot_dir", "snapshots"))
|
||||
path.mkdir(exist_ok=True)
|
||||
os.startfile(str(path))
|
||||
|
||||
def _open_motion_clips(self, icon=None, item=None) -> None:
|
||||
"""Open the motion-clips folder. Reachable only via the tray icon -
|
||||
while the whiteout is up the escape-key hook prevents reaching the
|
||||
tray, so this is effectively a post-authentication action.
|
||||
"""
|
||||
path = Path(getattr(self, "motion_clips_dir", "motion_clips"))
|
||||
path.mkdir(exist_ok=True)
|
||||
os.startfile(str(path))
|
||||
|
||||
def _open_log(self, icon=None, item=None) -> None:
|
||||
log_path = Path(self.config.get("log_file", "events.log"))
|
||||
if log_path.exists():
|
||||
os.startfile(str(log_path))
|
||||
else:
|
||||
try:
|
||||
self.icon.notify("No log file yet.", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _send_test_email(self, icon=None, item=None) -> None:
|
||||
try:
|
||||
self.actions.send_test_email()
|
||||
self.icon.notify("Test email queued. Check inbox / spam.", "ICEYOU")
|
||||
except Exception as e:
|
||||
try:
|
||||
self.icon.notify(f"Test email failed: {e}", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _open_settings(self, icon=None, item=None) -> None:
|
||||
"""Open config.json in the default text editor.
|
||||
|
||||
We avoid creating a second Tk root here because WhiteScreen owns a
|
||||
persistent Tk root on its own thread, and multiple Tk interpreters
|
||||
across threads are unsafe and freeze the tray.
|
||||
"""
|
||||
cfg_path = Path("config.json")
|
||||
if not cfg_path.exists():
|
||||
example = Path("config.example.json")
|
||||
if example.exists():
|
||||
try:
|
||||
cfg_path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
subprocess.Popen(["notepad.exe", str(cfg_path)])
|
||||
except Exception as e:
|
||||
try:
|
||||
self.icon.notify(f"Open settings failed: {e}", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.icon.notify("Edit config.json then restart ICEYOU", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
self.icon.notify("config.json not found", "ICEYOU")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _exit_app(self, icon=None, item=None) -> None:
|
||||
if self._hotkey_listener:
|
||||
try:
|
||||
self._hotkey_listener.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.monitor.stop()
|
||||
except Exception:
|
||||
pass
|
||||
# Stop the tray icon; this causes icon.run() to return.
|
||||
# Do NOT call sys.exit here - it triggers a message handler error in pystray.
|
||||
self.icon.stop()
|
||||
|
||||
def run(self) -> None:
|
||||
# Pre-warm WhiteScreen Tk thread so the very first show() never blocks
|
||||
# the tray callback waiting for the interpreter to boot.
|
||||
try:
|
||||
self.monitor.white_screen._ensure_ui_thread()
|
||||
except Exception as e:
|
||||
print(f"WhiteScreen pre-warm failed: {e}")
|
||||
if self.config.get("monitoring_enabled", True):
|
||||
self.monitor.start()
|
||||
if self._hotkey_listener:
|
||||
self._hotkey_listener.start()
|
||||
self.icon.run()
|
||||
# icon.run() returned because _exit_app called icon.stop()
|
||||
sys.exit(0)
|
||||
34
src/iceyou/utils.py
Normal file
34
src/iceyou/utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Windows utility functions: idle time, workstation lock, etc."""
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# Windows API: LASTINPUTINFO struct for idle detection
|
||||
class LASTINPUTINFO(ctypes.Structure):
|
||||
_fields_ = [("cbSize", wintypes.UINT), ("dwTime", wintypes.DWORD)]
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
def get_idle_time_seconds() -> float:
|
||||
"""Return seconds since last user input (keyboard or mouse)."""
|
||||
last_input = LASTINPUTINFO()
|
||||
last_input.cbSize = ctypes.sizeof(LASTINPUTINFO)
|
||||
if user32.GetLastInputInfo(ctypes.byref(last_input)):
|
||||
millis = kernel32.GetTickCount() - last_input.dwTime
|
||||
return millis / 1000.0
|
||||
return 0.0
|
||||
|
||||
def lock_workstation() -> bool:
|
||||
"""Lock the current workstation (same as Win+L)."""
|
||||
return bool(user32.LockWorkStation())
|
||||
|
||||
def get_timestamp() -> str:
|
||||
"""ISO-like timestamp for filenames and logs."""
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
||||
|
||||
def format_event(event_type: str, details: str) -> str:
|
||||
"""Simple event string."""
|
||||
return f"[{datetime.now().isoformat()}] {event_type}: {details}"
|
||||
Loading…
Reference in New Issue
Block a user