diff --git a/.gitignore b/.gitignore index 18dab12..1f5d81a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,9 @@ env/ config.json *.log snapshots/ +motion_clips/ faces/ events.log -motion_clips/ backup/ # IDE diff --git a/README.md b/README.md index fb3c034..6eb74f0 100644 --- a/README.md +++ b/README.md @@ -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. -## 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 | @@ -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`. -- 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. +2. **Copy & edit config**: + ```powershell + copy config.example.json config.json + notepad config.json + ``` + - Set `unlock_password` to your phrase. + - Fill SMTP details (Gmail App Password recommended). + - 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 - -- **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) | +5. *(Optional)* Use the **Add to Windows Startup** option (via `startup.py`) so ICEYOU launches at login. --- @@ -213,16 +62,23 @@ Or run as a scheduled task (recommended for reliability). |---|---| | `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+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 (`+++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 | 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 White Screen | Disable the whiteout while still monitoring | | 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, "use_tls": true, "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", "to_addr": "alerts@you.com", "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 "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 + "unlock_password": "iceyou2026!", // Your secret phrase "max_unlock_attempts": 3, // Wrong passwords before OS lock "block_escape_keys": true, // Install OS keyboard hook while locked diff --git a/backup b/backup new file mode 100644 index 0000000..76f6fb8 --- /dev/null +++ b/backup @@ -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 \ No newline at end of file diff --git a/config.example.json b/config.example.json index 4b4e7b2..b22225d 100644 --- a/config.example.json +++ b/config.example.json @@ -32,7 +32,9 @@ }, "hotkeys": { "force_away": "++l", - "unlock": "++u" + "unlock": "++u", + "toggle_monitoring": "++m", + "stop_monitoring": "++s" }, "face_recognition_enabled": false, "face_recognition": { diff --git a/src/iceyou/monitor.py b/src/iceyou/monitor.py index c7c43bf..b6f7b9c 100644 --- a/src/iceyou/monitor.py +++ b/src/iceyou/monitor.py @@ -24,6 +24,7 @@ class Monitor: 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.paused = False # When True, monitoring (idle detection, hooks, motion) is suspended self.event_queue: Queue = Queue() self._running = False @@ -124,6 +125,16 @@ class Monitor: check_interval = obs_cfg.get("check_interval_seconds", 12) 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() now = time.time() snoozed = now < self.snooze_until @@ -135,17 +146,17 @@ class Monitor: 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) + # 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 self.white_screen.enabled and - self.white_screen.is_shown and + not self.white_screen.is_shown and self.camera.obscured_enabled): now = time.time() if now - last_obscured_check >= check_interval: @@ -192,4 +203,40 @@ class Monitor: 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() \ No newline at end of file + 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() \ No newline at end of file diff --git a/src/iceyou/tray_app.py b/src/iceyou/tray_app.py index ef6b163..6cdbfd7 100644 --- a/src/iceyou/tray_app.py +++ b/src/iceyou/tray_app.py @@ -181,9 +181,11 @@ class TrayApp: def _on_unlocked(self) -> None: self.monitor.set_away(False) + # Automatically pause monitoring when user is present + self.monitor.pause_monitoring() try: if self.icon: - self.icon.notify("Whiteout unlocked", "ICEYOU") + self.icon.notify("Whiteout unlocked - Monitoring paused", "ICEYOU") except Exception: pass @@ -200,8 +202,8 @@ class TrayApp: def _create_icon(self) -> None: menu = Menu( - MenuItem("Start Monitoring", self._start_monitoring, default=True), MenuItem("Stop Monitoring", self._stop_monitoring), + MenuItem("Resume Monitoring", self._resume_monitoring), MenuItem("Toggle Away Mode", self._toggle_away), MenuItem("Toggle White Screen", self._toggle_white_screen), Menu.SEPARATOR, @@ -225,8 +227,16 @@ class TrayApp: self.icon.notify("Monitoring started", "ICEYOU") def _stop_monitoring(self, icon=None, item=None) -> None: - self.monitor.stop() - self.icon.notify("Monitoring stopped", "ICEYOU") + self.monitor.pause_monitoring() + 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: new_state = not self.monitor.is_away @@ -247,6 +257,8 @@ class TrayApp: hotkeys = self.config.get("hotkeys", {}) force_away_combo = hotkeys.get("force_away", "++l") unlock_combo = hotkeys.get("unlock", "++u") + toggle_monitor_combo = hotkeys.get("toggle_monitoring", "++m") + stop_monitor_combo = hotkeys.get("stop_monitoring", "++s") def on_force_away(): self.monitor.set_away(True) @@ -261,10 +273,33 @@ class TrayApp: self.monitor.white_screen.enabled = True 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: self._hotkey_listener = keyboard.GlobalHotKeys({ force_away_combo: on_force_away, unlock_combo: on_unlock, + toggle_monitor_combo: on_toggle_monitoring, + stop_monitor_combo: on_stop_monitoring, }) except Exception as e: print(f"Hotkey registration failed (check combo syntax): {e}") diff --git a/src/iceyou/white_screen.py b/src/iceyou/white_screen.py new file mode 100644 index 0000000..d48287b --- /dev/null +++ b/src/iceyou/white_screen.py @@ -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("", 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.") diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..bfa48b5 --- /dev/null +++ b/test_email.py @@ -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"))