Initial commit - ICEYOU v1.2 (changes to settings and overrun camera monitoring))

This commit is contained in:
Subinacls 2026-06-05 06:17:07 -04:00
parent 24cd01e46c
commit e3313dbc1b
8 changed files with 742 additions and 191 deletions

2
.gitignore vendored
View File

@ -30,9 +30,9 @@ env/
config.json config.json
*.log *.log
snapshots/ snapshots/
motion_clips/
faces/ faces/
events.log events.log
motion_clips/
backup/ backup/
# IDE # IDE

218
README.md
View File

@ -1,10 +1,7 @@
# ICEYOU - Personal Anti-Intrusion Monitor v:1.2 # ICEYOU - Personal Anti-Intrusion Monitor
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. 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 ## What It Does
| Capability | Detail | | Capability | Detail |
@ -25,185 +22,37 @@ This project was derived for the traveler or the individual who has a creeper in
--- ---
## Security Notice (Important for Gitea / Remote Hosting) ## Quick Start
**Never commit `config.json`** — it contains your unlock passphrase and email credentials. 1. **Create a virtualenv** (recommended — opencv-contrib requires numpy < 2):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
```
- `config.json` is already listed in `.gitignore`. 2. **Copy & edit config**:
- Only `config.example.json` (with placeholders) should ever be pushed to Gitea or any remote repository. ```powershell
- Before pushing, always run: copy config.example.json config.json
```powershell notepad config.json
git status ```
git diff --cached --name-only - Set `unlock_password` to your phrase.
``` - Fill SMTP details (Gmail App Password recommended).
and verify that `config.json`, `events.log`, `faces/`, `snapshots/`, and `motion_clips/` do **not** appear. - Pick the correct `camera_device_index` (run `python face_enrollment.py --list-cameras` to enumerate).
If you accidentally stage `config.json`, use `git rm --cached config.json` immediately. 3. **(Optional) Enroll your face**:
```powershell
python face_enrollment.py --name owner
```
Re-run with the same `--name` for each accessory combination (glasses, headphones, hat) to build a robust model.
--- 4. **Run**:
```powershell
python main.py
```
A red shield tray icon appears. Right-click for the menu.
## Prerequisites 5. *(Optional)* Use the **Add to Windows Startup** option (via `startup.py`) so ICEYOU launches at login.
- **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) |
--- ---
@ -213,16 +62,23 @@ Or run as a scheduled task (recommended for reliability).
|---|---| |---|---|
| `Ctrl+Alt+L` | Force away mode + show whiteout immediately | | `Ctrl+Alt+L` | Force away mode + show whiteout immediately |
| `Ctrl+Alt+U` | Re-show the whiteout password prompt (useful if it was hidden) | | `Ctrl+Alt+U` | Re-show the whiteout password prompt (useful if it was hidden) |
| `Ctrl+Alt+M` | **Toggle Monitoring** — Pause or resume monitoring (motion + intrusion detection) |
| `Ctrl+Alt+S` | **Stop Monitoring** — Immediately pause motion recording and intrusion detection |
Combos are configurable in `config.json → hotkeys` using pynput syntax (`<ctrl>+<shift>+<alt>+p`, etc.). Restart ICEYOU after editing. Combos are configurable in `config.json → hotkeys` using pynput syntax (`<ctrl>+<shift>+<alt>+p`, etc.). Restart ICEYOU after editing.
**Automatic behavior**: When the user successfully unlocks the whiteout, monitoring is automatically **paused** (no more motion recording or intrusion detection). It will **automatically resume** the next time the machine idles for the configured timeout.
--- ---
## Tray Menu ## Tray Menu
| Item | Description | | Item | Description |
|---|---| |---|---|
| Start / Stop Monitoring | Toggle monitor thread | | **Stop Monitoring** | Immediately pauses motion recording, intrusion detection, **and releases the camera** |
| **Resume Monitoring** | Re-enables idle detection, motion recording, and intrusion triggers |
| Toggle Away Mode | Manual away on/off (away forces the whiteout) | | Toggle Away Mode | Manual away on/off (away forces the whiteout) |
| Toggle White Screen | Disable the whiteout while still monitoring | | Toggle White Screen | Disable the whiteout while still monitoring |
| Open Snapshots Folder | Browse all captured photos | | Open Snapshots Folder | Browse all captured photos |
@ -392,7 +248,7 @@ To harden against CTRL+ALT+DEL → Task Manager, run as Administrator and set th
"smtp_port": 587, "smtp_port": 587,
"use_tls": true, "use_tls": true,
"username": "you@gmail.com", "username": "you@gmail.com",
"password": "YOUR_16_CHAR_APP_PASSWORD", // Gmail App Password (requires 2FA) "password": "APP_PASSWORD", // Gmail App Password, no spaces
"from_addr": "you@gmail.com", "from_addr": "you@gmail.com",
"to_addr": "alerts@you.com", "to_addr": "alerts@you.com",
"subject_prefix": "[ICEYOU Alert]" "subject_prefix": "[ICEYOU Alert]"
@ -401,7 +257,7 @@ To harden against CTRL+ALT+DEL → Task Manager, run as Administrator and set th
"lock_workstation": true, // OS lock after max_unlock_attempts password fails "lock_workstation": true, // OS lock after max_unlock_attempts password fails
"device_monitoring": true, // Watch USB drives "device_monitoring": true, // Watch USB drives
"white_screen_on_away": true, // Show the whiteout when away "white_screen_on_away": true, // Show the whiteout when away
"unlock_password": "CHANGE_ME_STRONG_PASSPHRASE", // Your secret phrase "unlock_password": "iceyou2026!", // Your secret phrase
"max_unlock_attempts": 3, // Wrong passwords before OS lock "max_unlock_attempts": 3, // Wrong passwords before OS lock
"block_escape_keys": true, // Install OS keyboard hook while locked "block_escape_keys": true, // Install OS keyboard hook while locked

13
backup Normal file
View File

@ -0,0 +1,13 @@
#backup
#ICEYOU2026!
#6509 3423
#9962 7773
#9291 7449
#8860 9723
#4286 2375
#9428 4672
#2884 3483
#6359 1603
#7872 4290
#2926 2500

View File

@ -32,7 +32,9 @@
}, },
"hotkeys": { "hotkeys": {
"force_away": "<ctrl>+<alt>+l", "force_away": "<ctrl>+<alt>+l",
"unlock": "<ctrl>+<alt>+u" "unlock": "<ctrl>+<alt>+u",
"toggle_monitoring": "<ctrl>+<alt>+m",
"stop_monitoring": "<ctrl>+<alt>+s"
}, },
"face_recognition_enabled": false, "face_recognition_enabled": false,
"face_recognition": { "face_recognition": {

View File

@ -24,6 +24,7 @@ class Monitor:
self.last_trigger_time = 0.0 self.last_trigger_time = 0.0
self.cooldown_seconds = 30 # Avoid spam triggers self.cooldown_seconds = 30 # Avoid spam triggers
self.snooze_until = 0.0 # When > now, skip auto-away based on idle self.snooze_until = 0.0 # When > now, skip auto-away based on idle
self.paused = False # When True, monitoring (idle detection, hooks, motion) is suspended
self.event_queue: Queue = Queue() self.event_queue: Queue = Queue()
self._running = False self._running = False
@ -124,6 +125,16 @@ class Monitor:
check_interval = obs_cfg.get("check_interval_seconds", 12) check_interval = obs_cfg.get("check_interval_seconds", 12)
while self._running: while self._running:
if self.paused:
# While paused, check if we should auto-resume on long idle
idle = get_idle_time_seconds()
if idle >= self.idle_timeout:
self.resume_monitoring()
# After resuming, fall through to normal away logic
else:
time.sleep(5)
continue
idle = get_idle_time_seconds() idle = get_idle_time_seconds()
now = time.time() now = time.time()
snoozed = now < self.snooze_until snoozed = now < self.snooze_until
@ -135,17 +146,17 @@ class Monitor:
if self.white_screen.enabled: if self.white_screen.enabled:
self.white_screen.show() self.white_screen.show()
elif not should_be_away and self.is_away and not snoozed and not self.manual_lock: 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: if idle < self.idle_timeout:
self.is_away = False self.is_away = False
self._log_event("state_change", "User returned") self._log_event("state_change", "User returned")
if self.white_screen.enabled: if self.white_screen.enabled:
self.white_screen.hide() self.white_screen.hide()
# Camera obstruction detection (only when away + white screen active) # Camera obstruction detection — only before the white screen is shown.
# Once the whiteout is active, obstruction is no longer relevant.
if (self.is_away and if (self.is_away and
self.white_screen.enabled and self.white_screen.enabled and
self.white_screen.is_shown and not self.white_screen.is_shown and
self.camera.obscured_enabled): self.camera.obscured_enabled):
now = time.time() now = time.time()
if now - last_obscured_check >= check_interval: if now - last_obscured_check >= check_interval:
@ -193,3 +204,39 @@ class Monitor:
self.last_trigger_time = time.time() self.last_trigger_time = time.time()
if self.white_screen.enabled: if self.white_screen.enabled:
self.white_screen.hide() self.white_screen.hide()
def pause_monitoring(self) -> None:
"""Temporarily suspend monitoring (no idle detection, no motion, no intrusions).
Used when the user is actively present.
"""
if self.paused:
return
self.paused = True
self._log_event("monitoring_paused", "Monitoring paused (user present)")
# Stop hooks and device monitoring
self.input_hooks.stop()
self.device_monitor.stop()
# Release camera when monitoring is paused
self.camera.release()
# Hide white screen + stop motion recorders if active
if self.white_screen.is_shown:
self.white_screen.hide()
def resume_monitoring(self) -> None:
"""Resume monitoring after being paused."""
if not self.paused:
return
self.paused = False
self._log_event("monitoring_resumed", "Monitoring resumed")
# Restart hooks
self.input_hooks.start()
if self.config.get("device_monitoring", True):
self.device_monitor.start()
# Reset idle tracking so we don't immediately go away
self.snooze_until = time.time() + max(self.idle_timeout, 60)
self.last_trigger_time = time.time()

View File

@ -181,9 +181,11 @@ class TrayApp:
def _on_unlocked(self) -> None: def _on_unlocked(self) -> None:
self.monitor.set_away(False) self.monitor.set_away(False)
# Automatically pause monitoring when user is present
self.monitor.pause_monitoring()
try: try:
if self.icon: if self.icon:
self.icon.notify("Whiteout unlocked", "ICEYOU") self.icon.notify("Whiteout unlocked - Monitoring paused", "ICEYOU")
except Exception: except Exception:
pass pass
@ -200,8 +202,8 @@ class TrayApp:
def _create_icon(self) -> None: def _create_icon(self) -> None:
menu = Menu( menu = Menu(
MenuItem("Start Monitoring", self._start_monitoring, default=True),
MenuItem("Stop Monitoring", self._stop_monitoring), MenuItem("Stop Monitoring", self._stop_monitoring),
MenuItem("Resume Monitoring", self._resume_monitoring),
MenuItem("Toggle Away Mode", self._toggle_away), MenuItem("Toggle Away Mode", self._toggle_away),
MenuItem("Toggle White Screen", self._toggle_white_screen), MenuItem("Toggle White Screen", self._toggle_white_screen),
Menu.SEPARATOR, Menu.SEPARATOR,
@ -225,8 +227,16 @@ class TrayApp:
self.icon.notify("Monitoring started", "ICEYOU") self.icon.notify("Monitoring started", "ICEYOU")
def _stop_monitoring(self, icon=None, item=None) -> None: def _stop_monitoring(self, icon=None, item=None) -> None:
self.monitor.stop() self.monitor.pause_monitoring()
self.icon.notify("Monitoring stopped", "ICEYOU") self.icon.notify("Monitoring stopped (paused)", "ICEYOU")
def _pause_monitoring(self, icon=None, item=None) -> None:
self.monitor.pause_monitoring()
self.icon.notify("Monitoring paused", "ICEYOU")
def _resume_monitoring(self, icon=None, item=None) -> None:
self.monitor.resume_monitoring()
self.icon.notify("Monitoring resumed", "ICEYOU")
def _toggle_away(self, icon=None, item=None) -> None: def _toggle_away(self, icon=None, item=None) -> None:
new_state = not self.monitor.is_away new_state = not self.monitor.is_away
@ -247,6 +257,8 @@ class TrayApp:
hotkeys = self.config.get("hotkeys", {}) hotkeys = self.config.get("hotkeys", {})
force_away_combo = hotkeys.get("force_away", "<ctrl>+<alt>+l") force_away_combo = hotkeys.get("force_away", "<ctrl>+<alt>+l")
unlock_combo = hotkeys.get("unlock", "<ctrl>+<alt>+u") unlock_combo = hotkeys.get("unlock", "<ctrl>+<alt>+u")
toggle_monitor_combo = hotkeys.get("toggle_monitoring", "<ctrl>+<alt>+m")
stop_monitor_combo = hotkeys.get("stop_monitoring", "<ctrl>+<alt>+s")
def on_force_away(): def on_force_away():
self.monitor.set_away(True) self.monitor.set_away(True)
@ -261,10 +273,33 @@ class TrayApp:
self.monitor.white_screen.enabled = True self.monitor.white_screen.enabled = True
self.monitor.white_screen.show() self.monitor.white_screen.show()
def on_toggle_monitoring():
if self.monitor.paused:
self.monitor.resume_monitoring()
try:
self.icon.notify("Monitoring resumed via hotkey", "ICEYOU")
except Exception:
pass
else:
self.monitor.pause_monitoring()
try:
self.icon.notify("Monitoring paused via hotkey", "ICEYOU")
except Exception:
pass
def on_stop_monitoring():
self.monitor.pause_monitoring()
try:
self.icon.notify("Monitoring stopped via hotkey", "ICEYOU")
except Exception:
pass
try: try:
self._hotkey_listener = keyboard.GlobalHotKeys({ self._hotkey_listener = keyboard.GlobalHotKeys({
force_away_combo: on_force_away, force_away_combo: on_force_away,
unlock_combo: on_unlock, unlock_combo: on_unlock,
toggle_monitor_combo: on_toggle_monitoring,
stop_monitor_combo: on_stop_monitoring,
}) })
except Exception as e: except Exception as e:
print(f"Hotkey registration failed (check combo syntax): {e}") print(f"Hotkey registration failed (check combo syntax): {e}")

567
src/iceyou/white_screen.py Normal file
View File

@ -0,0 +1,567 @@
"""Persistent fullscreen white-screen lockout with embedded password unlock.
Uses a SINGLE Tk root for the lifetime of the app to avoid Tcl async-delete
crashes when show/hide is called rapidly from multiple threads. The Tk root
and mainloop live on a dedicated UI thread. All other threads communicate
with the UI via `root.after()` (which is thread-safe).
"""
import threading
import time
import tkinter as tk
from tkinter import StringVar
from typing import Callable, Optional
from pynput import keyboard, mouse
from .keyboard_block import KeyboardBlocker
class WhiteScreen:
def __init__(
self,
enabled: bool = True,
verify_password: Optional[Callable[[str], bool]] = None,
on_failed_attempt: Optional[Callable[[int], None]] = None,
on_unlocked: Optional[Callable[[], None]] = None,
max_attempts: int = 3,
face_unlock: Optional[Callable[[], tuple]] = None,
face_unlock_auto: bool = False,
face_unlock_interval: float = 4.0,
block_escape_keys: bool = True,
on_attempt: Optional[Callable[[dict], None]] = None,
motion_recorder=None,
motion_recorders=None,
dimming_enabled: bool = True,
dimming_duration_seconds: float = 60.0,
target_grey: str = "#000000",
):
self.enabled = enabled
self.verify_password = verify_password or (lambda s: False)
self.on_failed_attempt = on_failed_attempt or (lambda r: None)
self.on_unlocked = on_unlocked or (lambda: None)
self.max_attempts = max_attempts
# face_unlock() -> (matched: bool, label: Optional[str], confidence: Optional[float])
self.face_unlock = face_unlock
self.face_unlock_auto = face_unlock_auto
self.face_unlock_interval = max(1.5, float(face_unlock_interval))
self.block_escape_keys = block_escape_keys
# on_attempt(record: dict) - fired after each interactive unlock attempt
# record keys: method ('password'|'face'), success (bool),
# entered (str, only present for failed password),
# label (str|None, face only), confidence (float|None, face only)
self.on_attempt = on_attempt or (lambda rec: None)
self._key_blocker = KeyboardBlocker() if block_escape_keys else None
# Accept either a list (new multi-cam API) or a single recorder (back-compat)
recs = []
if motion_recorders:
recs = list(motion_recorders)
elif motion_recorder is not None:
recs = [motion_recorder]
self.motion_recorders = recs
# Dimming / brightness tuner settings (color only - no transparency)
self.dimming_enabled = dimming_enabled
self.dimming_duration = max(10.0, float(dimming_duration_seconds))
self.target_grey = target_grey or "#000000"
self._root: Optional[tk.Tk] = None
self._thread: Optional[threading.Thread] = None
self._ready_event = threading.Event()
self._shown = False
self._failed_count = 0
self._face_busy = False
self._status_var: Optional[StringVar] = None
self._pw_var: Optional[StringVar] = None
self._entry: Optional[tk.Entry] = None
self._face_btn: Optional[tk.Button] = None
self._title_label: Optional[tk.Label] = None
self._status_label: Optional[tk.Label] = None
# Dimmer state
self._dimmer_running = False
self._dimmer_thread: Optional[threading.Thread] = None
self._last_activity = time.time()
self._activity_listener_kb = None
self._activity_listener_mouse = None
@property
def is_shown(self) -> bool:
return self._shown
def _ensure_ui_thread(self) -> None:
"""Start the persistent Tk thread if not already running."""
if self._thread and self._thread.is_alive():
return
self._ready_event.clear()
self._thread = threading.Thread(target=self._run_ui, daemon=True)
self._thread.start()
# Wait briefly for root to initialize
self._ready_event.wait(timeout=3.0)
def show(self) -> None:
if not self.enabled:
return
self._ensure_ui_thread()
if not self._root:
return
self._failed_count = 0
try:
self._root.after(0, self._show_window)
except Exception:
pass
def hide(self) -> None:
if not self._root:
self._shown = False
return
try:
self._root.after(0, self._hide_window)
except Exception:
pass
# ---- methods that run on the Tk thread ----
def _show_window(self) -> None:
if not self._root:
return
try:
already_shown = self._shown
sw = self._root.winfo_screenwidth()
sh = self._root.winfo_screenheight()
self._root.geometry(f"{sw}x{sh}+0+0")
self._root.deiconify()
self._root.attributes("-topmost", True)
self._root.lift()
if not already_shown:
# Only reset field & status on a fresh show, otherwise we'd wipe
# the password the user is currently typing.
if self._pw_var:
self._pw_var.set("")
if self._status_var:
self._status_var.set("Enter unlock phrase to dismiss whiteout:")
if self._entry:
self._entry.focus_force()
# Install OS-level keyboard blocker for escape combos
if self._key_blocker is not None:
try:
self._key_blocker.start()
except Exception as e:
print(f"[WhiteScreen] keyboard blocker start failed: {e}")
# Start motion recording while locked
for rec in self.motion_recorders:
try:
rec.start()
except Exception as e:
print(f"[WhiteScreen] motion recorder start failed: {e}")
# Start brightness dimmer + activity listeners
if self.dimming_enabled:
self._last_activity = time.time()
self._start_dimmer()
self._start_activity_listeners()
self._shown = True
except Exception as e:
print(f"[WhiteScreen] show error: {e}")
def _hide_window(self) -> None:
if not self._root:
return
try:
self._root.withdraw()
self._shown = False
if self._key_blocker is not None:
try:
self._key_blocker.stop()
except Exception as e:
print(f"[WhiteScreen] keyboard blocker stop failed: {e}")
if self.motion_recorders:
for rec in self.motion_recorders:
try:
rec.stop()
except Exception as e:
print(f"[WhiteScreen] motion recorder stop failed: {e}")
# Stop dimmer and activity listeners, restore full white background
self._stop_dimmer()
self._stop_activity_listeners()
try:
self._root.configure(bg="white")
except Exception:
pass
except Exception as e:
print(f"[WhiteScreen] hide error: {e}")
# ---- brightness dimmer / tuner ----
def reset_brightness(self) -> None:
"""Reset to full brightness (pure white) and restart the dimming timer.
Called on any keyboard/mouse activity or camera motion while locked.
"""
self._last_activity = time.time()
if self._root:
try:
self._root.after(0, lambda: self._apply_brightness("#ffffff"))
except Exception:
pass
def _start_dimmer(self) -> None:
if self._dimmer_running:
return
self._dimmer_running = True
self._dimmer_thread = threading.Thread(target=self._dimmer_loop, daemon=True)
self._dimmer_thread.start()
def _stop_dimmer(self) -> None:
self._dimmer_running = False
if self._dimmer_thread:
self._dimmer_thread.join(timeout=1.0)
self._dimmer_thread = None
def _dimmer_loop(self) -> None:
"""Gradually shifts background color from white toward a darker grey.
No transparency is used the window stays fully opaque.
"""
step = 0.15 # check ~6-7 times per second
while self._dimmer_running and self._root:
try:
now = time.time()
elapsed = now - self._last_activity
progress = min(1.0, elapsed / self.dimming_duration)
# Interpolate background color (white -> target_grey)
grey = self._interpolate_color("#ffffff", self.target_grey, progress)
if self._root:
self._root.after(0, lambda g=grey: self._apply_brightness(g))
except Exception:
pass
time.sleep(step)
def _interpolate_color(self, start_hex: str, end_hex: str, t: float) -> str:
"""Simple linear interpolation between two #rrggbb colors."""
def hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def rgb_to_hex(rgb):
return "#{:02x}{:02x}{:02x}".format(*rgb)
s = hex_to_rgb(start_hex)
e = hex_to_rgb(end_hex)
r = int(s[0] + (e[0] - s[0]) * t)
g = int(s[1] + (e[1] - s[1]) * t)
b = int(s[2] + (e[2] - s[2]) * t)
return rgb_to_hex((r, g, b))
def _apply_brightness(self, bg_color: str) -> None:
"""Apply background color and invert text for readability from white to black."""
if not self._root:
return
try:
is_dark = self._is_dark_color(bg_color)
text_color = "#eeeeee" if is_dark else "#222222"
title_color = "#ff4444" if is_dark else "#b00020" # keep ICEYOU title prominent
self._root.configure(bg=bg_color)
for child in self._root.winfo_children():
if isinstance(child, tk.Frame):
child.configure(bg=bg_color)
for sub in child.winfo_children():
if isinstance(sub, tk.Label):
if sub is getattr(self, "_title_label", None):
sub.configure(bg=bg_color, fg=title_color)
elif sub is getattr(self, "_status_label", None):
sub.configure(bg=bg_color, fg=text_color)
else:
sub.configure(bg=bg_color, fg=text_color)
elif isinstance(sub, tk.Button):
sub.configure(bg=bg_color, fg=text_color)
elif isinstance(sub, tk.Entry):
sub.configure(bg=bg_color, fg=text_color,
insertbackground=text_color)
except Exception:
pass
def _is_dark_color(self, hex_color: str) -> bool:
"""Return True if the color is dark enough to need light text."""
try:
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
luminance = (r * 0.299 + g * 0.587 + b * 0.114) / 255
return luminance < 0.5
except Exception:
return True # default to dark treatment if parsing fails
def _start_activity_listeners(self) -> None:
"""Install lightweight pynput listeners that reset brightness on any input."""
self._stop_activity_listeners()
def on_any_activity(*_):
if self._shown:
self.reset_brightness()
try:
self._activity_listener_kb = keyboard.Listener(on_press=on_any_activity)
self._activity_listener_kb.start()
except Exception as e:
print(f"[WhiteScreen] keyboard activity listener failed: {e}")
try:
self._activity_listener_mouse = mouse.Listener(
on_move=on_any_activity,
on_click=on_any_activity,
on_scroll=on_any_activity,
)
self._activity_listener_mouse.start()
except Exception as e:
print(f"[WhiteScreen] mouse activity listener failed: {e}")
def _stop_activity_listeners(self) -> None:
if self._activity_listener_kb:
try:
self._activity_listener_kb.stop()
except Exception:
pass
self._activity_listener_kb = None
if self._activity_listener_mouse:
try:
self._activity_listener_mouse.stop()
except Exception:
pass
self._activity_listener_mouse = None
def _run_ui(self) -> None:
"""Runs on dedicated thread. Creates root + mainloop, never destroys until app exit."""
try:
root = tk.Tk()
self._root = root
root.title("ICEYOU")
root.configure(bg="white")
root.overrideredirect(True)
root.attributes("-topmost", True)
root.attributes("-alpha", 1.0) # always fully opaque
root.protocol("WM_DELETE_WINDOW", lambda: None)
# Pre-size to full screen (used by _show_window via geometry too)
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{sw}x{sh}+0+0")
root.withdraw() # Start hidden
# UI elements (built once)
card = tk.Frame(root, bg="white", bd=2, relief="solid",
highlightbackground="#cccccc", highlightthickness=1)
card.place(relx=0.5, rely=0.5, anchor="center", width=440, height=240)
self._title_label = tk.Label(
card, text="ICEYOU LOCKED",
bg="white", fg="#b00020",
font=("Segoe UI", 18, "bold")
)
self._title_label.pack(pady=(18, 4))
self._status_var = StringVar(value="Enter unlock phrase to dismiss whiteout:")
self._status_label = tk.Label(
card, textvariable=self._status_var,
bg="white", fg="#333333",
font=("Segoe UI", 10), wraplength=400, justify="center"
)
self._status_label.pack(pady=4)
self._pw_var = StringVar()
self._entry = tk.Entry(
card, textvariable=self._pw_var, show="*",
width=30, font=("Segoe UI", 12), justify="center"
)
self._entry.pack(pady=10)
tk.Button(
card, text="Unlock", command=self._on_submit,
width=14, font=("Segoe UI", 10, "bold")
).pack(pady=(6, 2))
if self.face_unlock is not None:
self._face_btn = tk.Button(
card, text="Unlock by Face", command=self._on_face_click,
width=18, font=("Segoe UI", 9)
)
self._face_btn.pack(pady=(0, 6))
root.bind("<Return>", lambda e: self._on_submit())
# Periodic re-assert topmost; only steal focus if entry doesn't have it
def keep_focus():
try:
if self._shown and self._root:
self._root.attributes("-topmost", True)
self._root.lift()
if self._entry:
try:
focused = self._root.focus_get()
except Exception:
focused = None
if focused is not self._entry:
self._entry.focus_force()
if self._root:
self._root.after(4000, keep_focus)
except Exception:
pass
root.after(4000, keep_focus)
# Periodic face unlock attempt while shown (if enabled)
def auto_face():
try:
if (self.face_unlock_auto and self._shown
and self.face_unlock is not None and not self._face_busy):
self._attempt_face_unlock(silent=True)
if self._root:
self._root.after(int(self.face_unlock_interval * 1000), auto_face)
except Exception:
pass
if self.face_unlock is not None and self.face_unlock_auto:
root.after(int(self.face_unlock_interval * 1000), auto_face)
self._ready_event.set()
root.mainloop()
except Exception as e:
print(f"[WhiteScreen] UI thread error: {e}")
self._ready_event.set()
finally:
self._root = None
self._shown = False
self._status_var = None
self._pw_var = None
self._entry = None
self._title_label = None
self._status_label = None
def _on_submit(self) -> None:
if not self._pw_var:
return
entered = self._pw_var.get().strip()
self._pw_var.set("")
try:
ok = self.verify_password(entered)
except Exception as e:
print(f"[WhiteScreen] verify error: {e}")
ok = False
# Fire attempt callback (entered only included on failure to avoid logging real password)
try:
record = {"method": "password", "success": ok}
if not ok:
record["entered"] = entered
self.on_attempt(record)
except Exception as e:
print(f"[WhiteScreen] on_attempt error: {e}")
if ok:
self._failed_count = 0
try:
self.on_unlocked()
except Exception as e:
print(f"[WhiteScreen] on_unlocked error: {e}")
self._hide_window()
return
self._failed_count += 1
remaining = max(0, self.max_attempts - self._failed_count)
if remaining == 0:
self._failed_count = 0
if self._status_var:
self._status_var.set("Too many failed attempts. Locking workstation.")
try:
self.on_failed_attempt(0)
except Exception as e:
print(f"[WhiteScreen] on_failed_attempt error: {e}")
if self._root and self._status_var:
status_var = self._status_var
self._root.after(
1500,
lambda: status_var.set("Enter unlock phrase to dismiss whiteout:")
)
else:
if self._status_var:
self._status_var.set(
f"Incorrect. {remaining} attempt(s) remaining before OS lock."
)
try:
self.on_failed_attempt(remaining)
except Exception:
pass
if self._entry:
self._entry.focus_force()
# ---- face unlock ----
def _on_face_click(self) -> None:
self._attempt_face_unlock(silent=False, manual=True)
def _attempt_face_unlock(self, silent: bool = False, manual: bool = False) -> None:
"""Run face_unlock callback in a background thread; on success unlock."""
if self.face_unlock is None or self._face_busy:
return
self._face_busy = True
if not silent and self._status_var:
self._status_var.set("Looking for your face...")
if self._face_btn:
try:
self._face_btn.config(state="disabled")
except Exception:
pass
def worker():
matched = False
label = None
conf = None
try:
result = self.face_unlock()
if isinstance(result, tuple):
matched = bool(result[0])
label = result[1] if len(result) > 1 else None
conf = result[2] if len(result) > 2 else None
else:
matched = bool(result)
except Exception as e:
print(f"[WhiteScreen] face_unlock error: {e}")
# Hand result back to Tk thread
if self._root:
try:
self._root.after(0, lambda: self._face_unlock_result(matched, label, conf, silent, manual))
except Exception:
self._face_busy = False
threading.Thread(target=worker, daemon=True).start()
def _face_unlock_result(self, matched: bool, label, conf, silent: bool, manual: bool = False) -> None:
self._face_busy = False
if self._face_btn:
try:
self._face_btn.config(state="normal")
except Exception:
pass
# Fire attempt log: always for manual clicks, only on success for auto (avoid flooding)
if manual or matched:
try:
self.on_attempt({
"method": "face",
"success": matched,
"label": label,
"confidence": float(conf) if conf is not None else None,
})
except Exception as e:
print(f"[WhiteScreen] on_attempt error: {e}")
if matched:
self._failed_count = 0
try:
self.on_unlocked()
except Exception as e:
print(f"[WhiteScreen] on_unlocked error: {e}")
self._hide_window()
return
if not silent and self._status_var:
if conf is not None:
self._status_var.set(
f"Face not recognised (best={label or '?'}, conf={conf:.1f}). Try the password."
)
else:
self._status_var.set("No face detected. Try the password.")

31
test_email.py Normal file
View File

@ -0,0 +1,31 @@
"""Quick email pipeline check - sends a synthetic 'failed unlock attempt' alert.
Run: python test_email.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from iceyou.config import Config
from iceyou.actions import Actions
cfg = Config()
a = Actions(cfg)
print("Sending plain test email...")
a._send_email(
subject="Wiring test - plain",
body="If you see this, SMTP works end-to-end.",
snapshot_path=None,
)
print("Sending simulated failed-password alert...")
a._send_email(
subject="FAILED unlock attempt (password) [TEST]",
body=(
"ICEYOU recorded a FAILED password unlock attempt.\n\n"
"Entered: 'hunter2'\n"
"Time: now\n\n"
"Snapshot of the person at the keyboard is attached."
),
snapshot_path=None,
)
print("Done. Check inbox / spam folder at:", cfg.get("email.to_addr"))