| """ |
| browser_session.py |
| InsightUX research browser. |
| |
| Flow: |
| 1. Window opens on real google.com — search and click through like a |
| normal browser (no scraping, no fake results — it's just Google). |
| 2. Land on whatever page you want to study. Press S to start tracking. |
| 3. Gaze dot + AOI highlighting (same as run_session.py) kicks in and |
| logs sessions/<timestamp>/gaze_log.jsonl + dom_log.jsonl. |
| 4. Press E to stop. The window auto-navigates to a generated |
| analysis_report.html: ranked attention, dwell timeline, heatmap. |
| |
| Prerequisite: |
| calibration.pkl must exist in the working directory (run calibrate.py first). |
| |
| Run: |
| python browser_session.py |
| """ |
|
|
| import os |
| import io |
| import re |
| import sys |
| import csv |
| import hmac |
| import json |
| import time |
| import math |
| import base64 |
| import logging |
| import secrets |
| import zipfile |
| import functools |
| import threading |
| import subprocess |
| from dataclasses import replace |
| from datetime import datetime |
| from logging.handlers import RotatingFileHandler |
|
|
| |
| |
| |
| |
| |
| if hasattr(sys.stdout, "reconfigure"): |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") |
| if hasattr(sys.stderr, "reconfigure"): |
| sys.stderr.reconfigure(encoding="utf-8", errors="replace") |
|
|
| import cv2 |
| import numpy as np |
| import webview |
| import pyautogui |
| from PIL import Image |
|
|
| from preprocessing.preprocessing_pipeline import ( |
| create_face_mesh, |
| estimate_camera_matrix, |
| estimate_head_pose, |
| compute_iris_radius, |
| compute_ear, |
| step1_normalize, |
| step2_illumination, |
| LEFT_EYE_INDICES, |
| LEFT_EAR_INDICES, |
| LEFT_IRIS_INDICES, |
| RIGHT_EYE_INDICES, |
| RIGHT_EAR_INDICES, |
| RIGHT_IRIS_INDICES, |
| ) |
| from inference_pipeline import InsightUXPipeline, GazeAngleSmoother |
| from session_logger import GazeLogger |
| from analysis import generate_report, load_session, session_summary, summarize_mouse |
| import theme |
| import auth |
| import participants |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| if getattr(sys, "frozen", False): |
| RESOURCE_DIR = sys._MEIPASS |
| DATA_DIR = os.path.dirname(sys.executable) |
| else: |
| RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| DATA_DIR = RESOURCE_DIR |
| BASE_DIR = DATA_DIR |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _LOG_DIR = os.path.join(DATA_DIR, "logs") |
| os.makedirs(_LOG_DIR, exist_ok=True) |
| _logger = logging.getLogger("insightux") |
| _logger.setLevel(logging.INFO) |
| _logger.propagate = False |
| _log_handler = RotatingFileHandler( |
| os.path.join(_LOG_DIR, "insightux.log"), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8" |
| ) |
| _log_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) |
| _logger.addHandler(_log_handler) |
|
|
|
|
| def _log(msg): |
| print(msg) |
| try: |
| _logger.info(msg) |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| VERSION = "1.0.0" |
| VERSION_CHECK_URL = "https://huggingface.co/arpitasethiii/insightux/resolve/main/version.json" |
|
|
| ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx") |
| CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl") |
| SCREEN_W, SCREEN_H = pyautogui.size() |
|
|
| PATCH_SOURCE = "blended" |
| POSE_SMOOTH = 0.65 |
| MAX_JUMP = 220 |
| NO_FACE_RESET = 15 |
| DOM_EVERY = 6 |
|
|
| EURO_MINCUTOFF = 0.35 |
| EURO_BETA = 0.12 |
|
|
| |
| |
| |
| ANGLE_SMOOTH_WINDOW = 10 |
|
|
| POSE_NORM_SCALE = 30.0 |
| HEAD_PITCH_COMPENSATION = 0.0 |
| HEAD_YAW_COMPENSATION = 0.0 |
|
|
| SESSIONS_ROOT = os.path.join(DATA_DIR, "sessions") |
|
|
| |
| |
| |
| |
| |
| _SESSION_ID_RE = re.compile(r"^\d{8}_\d{6}$") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _API_TOKEN = secrets.token_hex(24) |
|
|
|
|
| def _require_token(fn): |
| """Decorates every JS-reachable Api method that mutates state or reads |
| otherwise-private data. The token always arrives as the CALLER'S LAST |
| positional argument (CHROME_JS appends it to every window.pywebview.api |
| call it makes) -- stripped here before the wrapped method ever sees its |
| normal argument list, so method bodies below stay unaware this exists. |
| login()/create_profile()/list_profiles() are deliberately NOT wrapped: |
| they're only ever called from the login page's own self-contained |
| script, which (unlike CHROME_JS) never runs alongside third-party page |
| content in the first place. log_mouse_data() is also left unwrapped -- |
| see its own docstring for why that one's a deliberately accepted gap. |
| Rejecting returns None, which every existing call site already treats |
| as "nothing happened" (`res && res.ok`, `if (ok)`, or no .then() at |
| all) -- no call site needed to change to tolerate this.""" |
| @functools.wraps(fn) |
| def wrapper(self, *args): |
| token = args[-1] if args else None |
| if not isinstance(token, str) or not hmac.compare_digest(token, _API_TOKEN): |
| _log(f"[browser_session] rejected {fn.__name__}() -- missing/invalid API token " |
| f"(a page's own script calling window.pywebview.api directly?)") |
| return None |
| return fn(self, *args[:-1]) |
| return wrapper |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _LEGACY_CALIBRATION_PATH = CALIBRATION_PATH |
| _LEGACY_SESSIONS_ROOT = SESSIONS_ROOT |
| _LEGACY_BASELINE_POSE_PATH = os.path.join(DATA_DIR, "baseline_pose.pkl") |
|
|
| CURRENT_USER_DIR = None |
| CURRENT_SUBJECT_DIR = None |
| |
| |
| |
| |
| |
|
|
|
|
| def _user_onnx_path(): |
| """The active tracking subject's own fine-tuned gaze model if they've |
| ever calibrated with fine-tuning on, else the shared bundled default — |
| mirrors the write side of this in calibrate.py's out_onnx_path. A |
| subject (owner or participant) who has never calibrated is correctly |
| treated as running on the stock model, never on someone else's.""" |
| if CURRENT_SUBJECT_DIR: |
| candidate = os.path.join(CURRENT_SUBJECT_DIR, "gaze_cnn_v4_finetuned.onnx") |
| if os.path.exists(candidate): |
| return candidate |
| return ONNX_PATH |
|
|
|
|
| def _apply_user_paths(user_id): |
| """Owner-level redirection only — sets CURRENT_USER_DIR to this |
| profile's own folder. Session/calibration paths for the ACTIVE |
| TRACKING SUBJECT are computed separately by _apply_subject_paths(), |
| which the caller (Api._activate_user()) always calls right after this |
| (defaulting to self) — see that function for why the two are split.""" |
| global CURRENT_USER_DIR |
| CURRENT_USER_DIR = auth.user_dir(DATA_DIR, user_id) |
| os.makedirs(CURRENT_USER_DIR, exist_ok=True) |
|
|
|
|
| def _apply_subject_paths(participant_record): |
| """The one place SESSIONS_ROOT/CALIBRATION_PATH get repointed at the |
| active tracking subject's own folder — participant_record=None means |
| "Myself" (the owner's own folder, exactly the pre-participants |
| behavior), a participant dict means their nested |
| users/<owner>/participants/<id>/ folder. Every existing call site that |
| reads these two names (gaze_worker(), _sessions_json(), the |
| calibration-exists check in start_tracking(), ...) needed zero |
| changes — same reasoning _apply_user_paths() already documents.""" |
| global SESSIONS_ROOT, CALIBRATION_PATH, CURRENT_SUBJECT_DIR |
| if participant_record: |
| CURRENT_SUBJECT_DIR = participants.participant_dir(CURRENT_USER_DIR, participant_record["id"]) |
| else: |
| CURRENT_SUBJECT_DIR = CURRENT_USER_DIR |
| SESSIONS_ROOT = os.path.join(CURRENT_SUBJECT_DIR, "sessions") |
| CALIBRATION_PATH = os.path.join(CURRENT_SUBJECT_DIR, "calibration.pkl") |
| os.makedirs(SESSIONS_ROOT, exist_ok=True) |
|
|
|
|
| def _migrate_legacy_data(user_id): |
| """Runs exactly once, only for the very first profile ever created on |
| an install that already had pre-profile global data — folds that |
| existing calibration/sessions history into the new profile instead of |
| silently orphaning it. Never touched again for the 2nd+ profile.""" |
| import shutil |
| dest_dir = auth.user_dir(DATA_DIR, user_id) |
| os.makedirs(dest_dir, exist_ok=True) |
|
|
| if os.path.exists(_LEGACY_CALIBRATION_PATH): |
| dest = os.path.join(dest_dir, "calibration.pkl") |
| if not os.path.exists(dest): |
| shutil.move(_LEGACY_CALIBRATION_PATH, dest) |
| if os.path.exists(_LEGACY_BASELINE_POSE_PATH): |
| dest = os.path.join(dest_dir, "baseline_pose.pkl") |
| if not os.path.exists(dest): |
| shutil.move(_LEGACY_BASELINE_POSE_PATH, dest) |
| if os.path.isdir(_LEGACY_SESSIONS_ROOT): |
| dest_sessions = os.path.join(dest_dir, "sessions") |
| os.makedirs(dest_sessions, exist_ok=True) |
| for name in os.listdir(_LEGACY_SESSIONS_ROOT): |
| src = os.path.join(_LEGACY_SESSIONS_ROOT, name) |
| dst = os.path.join(dest_sessions, name) |
| if not os.path.exists(dst): |
| shutil.move(src, dst) |
|
|
|
|
| |
| |
| |
| |
| _LANDING_HTML = r"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="insightux-landing" content="true"> |
| <title>InsightUX — Eye-Tracking Research Browser</title> |
| <style> |
| __THEME_CSS__ |
| * { box-sizing: border-box; } |
| html, body { height: 100%; } |
| body { |
| margin: 0; display: flex; flex-direction: column; |
| align-items: center; justify-content: center; gap: 22px; |
| background: var(--iux-page-bg); |
| font-family: var(--iux-font); color: var(--iux-text); |
| transition: background 0.3s var(--iux-ease), color 0.3s var(--iux-ease); |
| overflow: hidden; /* the page itself never scrolls — only .recent-list does */ |
| padding: 16px 0; |
| } |
| .brand { text-align: center; } |
| .logo-row { display: flex; align-items: center; justify-content: center; gap: 12px; } |
| .logo-mark { |
| width: 46px; height: 46px; border-radius: 14px; background: var(--iux-accent-grad); |
| display: flex; align-items: center; justify-content: center; box-shadow: var(--iux-shadow-glow); |
| } |
| .logo-mark svg { color: var(--iux-on-accent); } |
| .logo { |
| font-size: 40px; font-weight: 700; letter-spacing: -0.02em; |
| background: var(--iux-accent-grad); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; |
| } |
| .tagline { color: var(--iux-text-dim); font-size: 14.5px; margin-top: 8px; } |
| .desc { color: var(--iux-text-faint); font-size: 12.5px; margin-top: 4px; max-width: 480px; } |
| |
| form { width: 560px; max-width: 88vw; position: relative; } |
| form .search-icon { |
| position: absolute; left: 18px; top: 50%; transform: translateY(-50%); color: var(--iux-text-faint); |
| pointer-events: none; |
| } |
| input { |
| width: 100%; padding: 16px 20px 16px 46px; font-size: 15px; border-radius: 28px; |
| border: 1.5px solid var(--iux-border); background: var(--iux-surface); color: var(--iux-text); |
| outline: none; transition: border-color 0.15s var(--iux-ease), box-shadow 0.15s var(--iux-ease); |
| box-shadow: var(--iux-shadow-sm); |
| } |
| input:focus { border-color: var(--iux-primary-light); box-shadow: var(--iux-shadow-glow); } |
| input::placeholder { color: var(--iux-text-faint); } |
| |
| .chips { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 640px; } |
| .chip { |
| display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px; |
| background: var(--iux-surface); border: 1px solid var(--iux-border); font-size: 12px; color: var(--iux-text-dim); |
| } |
| .chip kbd { |
| background: var(--iux-surface-hi); border: 1px solid var(--iux-border); border-radius: 6px; |
| padding: 1px 7px; font: 700 11px var(--iux-font); color: var(--iux-primary-light); |
| } |
| |
| .hint { |
| color: var(--iux-text-faint); text-align: center; line-height: 1.6; font-size: 11.5px; max-width: 520px; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="brand iux-fade-in"> |
| <div class="logo-row"> |
| <div class="logo-mark">__LOGO_ICON__</div> |
| <div class="logo">InsightUX</div> |
| </div> |
| <div class="tagline">AI-Powered Eye & Mouse Tracking Research Browser</div> |
| <div class="desc">Browse the real web, then press S to start studying attention — gaze, cursor, and clicks, all in one session.</div> |
| </div> |
| |
| <form id="searchForm" class="iux-fade-in"> |
| <span class="search-icon">__SEARCH_ICON__</span> |
| <input id="searchInput" type="text" autofocus |
| placeholder="Search Google, or enter a website address"> |
| </form> |
| |
| <div class="chips iux-fade-in"> |
| <span class="chip">__PLAY_ICON__ <kbd>S</kbd> Start Tracking</span> |
| <span class="chip">__STOP_ICON__ <kbd>E</kbd> Stop Tracking</span> |
| <span class="chip">__LAYERS_ICON__ <kbd>H</kbd> Heatmap</span> |
| <span class="chip">__CURSOR_ICON__ <kbd>M</kbd> Mouse Panel</span> |
| </div> |
| |
| <div class="hint"> |
| Search results and websites open below as normal. Once you land on the page you want |
| to study, press <b>S</b> to start eye-tracking, <b>E</b> to stop. |
| </div> |
| |
| <script> |
| __ICONS_JS__ |
| __THEME_JS__ |
| |
| document.getElementById('searchForm').addEventListener('submit', function(e){ |
| e.preventDefault(); |
| const raw = document.getElementById('searchInput').value.trim(); |
| if (!raw) return; |
| |
| const looksLikeUrl = /^https?:\/\//i.test(raw) || |
| (/^[\w-]+(\.[\w-]+)+([/?#].*)?$/i.test(raw) && !raw.includes(' ')); |
| |
| if (looksLikeUrl) { |
| window.location.href = raw.startsWith('http') ? raw : ('https://' + raw); |
| } else { |
| window.location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw); |
| } |
| }); |
| |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def _session_thumbnail_data_uri(session_dir, max_size=(96, 64)): |
| """Small base64 data: URI thumbnail from this session's own first |
| captured screenshot, if any. A plain file:// <img src> would silently |
| fail to load whenever the containing page is a real http(s) website |
| (Chromium blocks local-file subresource loads from non-file:// pages) |
| — the same class of problem Api._open_session_report() already works |
| around for "View Report" links, and exactly what analysis.py's own |
| _screenshot_data_uri() already sidesteps the same way for the report |
| page itself. Resized + JPEG-recompressed and cached as screenshots/ |
| _thumb.jpg next to the full-resolution PNGs (leading underscore keeps |
| it out of the shot_NNNN.png sequence analysis.py reads by exact |
| filename, never a directory listing, so this can't confuse it) so a |
| session list of many entries only pays the resize cost once, not on |
| every single time My Sessions/Persons is opened.""" |
| screens_dir = os.path.join(session_dir, "screenshots") |
| thumb_path = os.path.join(screens_dir, "_thumb.jpg") |
| if not os.path.exists(thumb_path): |
| if not os.path.isdir(screens_dir): |
| return None |
| shot_names = sorted(n for n in os.listdir(screens_dir) if n.lower().endswith(".png")) |
| if not shot_names: |
| return None |
| try: |
| img = Image.open(os.path.join(screens_dir, shot_names[0])) |
| img.thumbnail(max_size) |
| img.convert("RGB").save(thumb_path, format="JPEG", quality=60) |
| except Exception: |
| return None |
| try: |
| with open(thumb_path, "rb") as f: |
| return "data:image/jpeg;base64," + base64.b64encode(f.read()).decode("ascii") |
| except Exception: |
| return None |
|
|
|
|
| def _sessions_json(root, limit=25): |
| """Read-only directory listing of sessions/<timestamp> folders under |
| an explicitly-given root — the shared engine behind Profile -> My |
| Sessions and (Three-dot) Persons -> a specific participant's history. |
| Taking root as a parameter rather than always reading the global |
| SESSIONS_ROOT is what lets both show the *right* folder regardless of |
| whoever the Participant panel currently has selected. Never touches |
| session contents or tracking state. |
| |
| Each entry is matched to its own already-generated analysis_report.html |
| (written by generate_report() at session end, under that same session's |
| folder — the existing session-id -> report mapping already used |
| elsewhere) so "View Report" always opens THAT session's report, never |
| just the most recent one. Duration/sample-count/domain reuse analysis.py's |
| existing load_session()/session_summary() — same read-only computation |
| the report itself already does, not a new data path.""" |
| import datetime as _dt |
| from urllib.parse import urlparse |
|
|
| entries = [] |
| if os.path.isdir(root): |
| for name in os.listdir(root): |
| full = os.path.join(root, name) |
| if not os.path.isdir(full): |
| continue |
| try: |
| ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S") |
| except ValueError: |
| continue |
| entries.append((ts, name, full)) |
| entries.sort(key=lambda triple: triple[0], reverse=True) |
|
|
| out = [] |
| for ts, name, full in entries[:limit]: |
| report_path = os.path.join(full, "analysis_report.html") |
| if not os.path.exists(report_path): |
| |
| |
| |
| |
| |
| |
| try: |
| generate_report(full) |
| except Exception as e: |
| _log(f"[browser_session] could not (re)build report for {name}: {e}") |
| report_url = None |
| if os.path.exists(report_path): |
| report_url = "file://" + report_path.replace(os.sep, "/") |
|
|
| domain, duration, samples = None, None, None |
| try: |
| gaze, dom = load_session(full) |
| summary = session_summary(gaze, dom) |
| duration = summary["duration"] or None |
| samples = summary["samples"] or None |
| if summary["url"]: |
| domain = urlparse(summary["url"]).netloc or summary["url"] |
| except Exception: |
| pass |
|
|
| custom_name = None |
| try: |
| with open(os.path.join(full, "session_meta.json"), "r", encoding="utf-8") as f: |
| custom_name = (json.load(f).get("display_name") or "").strip() or None |
| except Exception: |
| pass |
|
|
| thumbnail = _session_thumbnail_data_uri(full) |
|
|
| out.append({ |
| "id": name, |
| "label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "), |
| "customName": custom_name, |
| "when": ts.strftime("%Y-%m-%d %H:%M"), |
| "domain": domain, |
| "duration": duration, |
| "samples": samples, |
| "reportUrl": report_url, |
| "thumbnail": thumbnail, |
| }) |
| return out |
|
|
|
|
| def _write_landing_page(): |
| path = os.path.join(DATA_DIR, "insightux_landing.html") |
| html = _LANDING_HTML |
| html = html.replace("__THEME_CSS__", theme.THEME_CSS) |
| html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS) |
| html = html.replace("__ICONS_JS__", theme.ICONS_JS) |
| html = html.replace("__LOGO_ICON__", theme.icon("eye", 24)) |
| html = html.replace("__SEARCH_ICON__", theme.icon("search", 18)) |
| html = html.replace("__PLAY_ICON__", theme.icon("play", 13)) |
| html = html.replace("__STOP_ICON__", theme.icon("stop", 13)) |
| html = html.replace("__LAYERS_ICON__", theme.icon("layers", 13)) |
| html = html.replace("__CURSOR_ICON__", theme.icon("cursor", 13)) |
| with open(path, "w", encoding="utf-8") as f: |
| f.write(html) |
| return "file://" + path.replace(os.sep, "/") |
|
|
|
|
| LANDING_URL = _write_landing_page() |
|
|
|
|
| |
| |
| |
| |
| |
| _LOGIN_HTML = r"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="insightux-login" content="true"> |
| <title>InsightUX — Sign in</title> |
| <style> |
| __THEME_CSS__ |
| * { box-sizing: border-box; } |
| html, body { height: 100%; } |
| body { |
| margin: 0; display: flex; flex-direction: column; |
| align-items: center; justify-content: center; gap: 20px; |
| background: var(--iux-page-bg); |
| font-family: var(--iux-font); color: var(--iux-text); |
| transition: background 0.3s var(--iux-ease), color 0.3s var(--iux-ease); |
| overflow: hidden; |
| padding: 16px 0; |
| } |
| .theme-toggle { position: fixed; top: 20px; right: 22px; width: 38px; height: 38px; padding: 0; } |
| .brand { text-align: center; } |
| .logo-row { display: flex; align-items: center; justify-content: center; gap: 12px; } |
| .logo-mark { |
| width: 42px; height: 42px; border-radius: 13px; background: var(--iux-accent-grad); |
| display: flex; align-items: center; justify-content: center; box-shadow: var(--iux-shadow-glow); |
| } |
| .logo-mark svg { color: var(--iux-on-accent); } |
| .logo { |
| font-size: 32px; font-weight: 700; letter-spacing: -0.02em; |
| background: var(--iux-accent-grad); |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; |
| } |
| .tagline { color: var(--iux-text-dim); font-size: 13px; margin-top: 6px; } |
| |
| .auth-card { width: 380px; max-width: 90vw; padding: 22px; display: flex; flex-direction: column; gap: 14px; } |
| .auth-tabs { display: flex; gap: 6px; background: var(--iux-surface-hi); padding: 3px; border-radius: 999px; } |
| .auth-tab { |
| flex: 1 1 auto; border: none; background: transparent; padding: 8px; border-radius: 999px; |
| color: var(--iux-text-dim); font: 600 12.5px var(--iux-font); cursor: pointer; |
| } |
| .auth-tab.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); } |
| |
| .profile-list { display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; } |
| .profile-row { |
| display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; |
| border: 1px solid var(--iux-border); cursor: pointer; transition: background .12s ease; |
| } |
| .profile-row:hover { background: var(--iux-surface-hi); } |
| .profile-row .avatar { |
| width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; |
| background: var(--iux-accent-grad); color: var(--iux-on-accent); |
| display: flex; align-items: center; justify-content: center; font: 700 13px var(--iux-font); |
| } |
| .profile-row .who { min-width: 0; flex: 1 1 auto; } |
| .profile-row .name { |
| font: 600 12.5px var(--iux-font); color: var(--iux-text); |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| } |
| .profile-row .email { |
| font-size: 11px; color: var(--iux-text-faint); |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| } |
| |
| .field { display: flex; flex-direction: column; gap: 5px; } |
| .field label { font-size: 11px; color: var(--iux-text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } |
| .field input { |
| padding: 10px 12px; font-size: 13.5px; border-radius: 10px; |
| border: 1.5px solid var(--iux-border); background: var(--iux-surface); color: var(--iux-text); |
| outline: none; transition: border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease); |
| } |
| .field input:focus { border-color: var(--iux-primary-light); box-shadow: var(--iux-shadow-glow); } |
| |
| .auth-error { |
| display: none; font-size: 12px; color: var(--iux-danger); background: rgba(251,113,133,0.1); |
| border: 1px solid rgba(251,113,133,0.3); border-radius: 8px; padding: 8px 10px; |
| } |
| .auth-error.on { display: block; } |
| |
| .auth-submit { |
| padding: 11px; border-radius: 10px; border: none; background: var(--iux-accent-grad); |
| color: var(--iux-on-accent); font: 700 13px var(--iux-font); cursor: pointer; |
| transition: transform .15s var(--iux-ease), box-shadow .15s var(--iux-ease); |
| } |
| .auth-submit:hover { box-shadow: var(--iux-shadow-glow); } |
| .auth-submit:disabled { opacity: .6; cursor: default; } |
| |
| .auth-back { background: transparent; border: none; color: var(--iux-text-faint); font-size: 11.5px; cursor: pointer; text-align: left; padding: 0; } |
| .auth-back:hover { color: var(--iux-text); } |
| |
| .auth-empty { font-size: 12px; color: var(--iux-text-faint); text-align: center; padding: 18px 0; } |
| .auth-hint { color: var(--iux-text-faint); font-size: 11px; text-align: center; max-width: 380px; } |
| </style> |
| </head> |
| <body> |
| <button type="button" class="theme-toggle iux-btn" id="themeToggle" title="Toggle theme"></button> |
| |
| <div class="brand iux-fade-in"> |
| <div class="logo-row"> |
| <div class="logo-mark">__LOGO_ICON__</div> |
| <div class="logo">InsightUX</div> |
| </div> |
| <div class="tagline">Sign in to your profile to continue</div> |
| </div> |
| |
| <div class="auth-card iux-card iux-fade-in"> |
| <div class="auth-tabs"> |
| <button type="button" class="auth-tab on" id="tabLogin">Log in</button> |
| <button type="button" class="auth-tab" id="tabCreate">Create profile</button> |
| </div> |
| |
| <div id="loginPane"> |
| <div id="profileListWrap"> |
| <div class="profile-list" id="profileList"></div> |
| </div> |
| <div id="passwordWrap" style="display:none; flex-direction:column; gap:14px;"> |
| <button type="button" class="auth-back" id="backToList">← Back to profiles</button> |
| <div class="field"> |
| <label id="passwordForLabel">Password</label> |
| <input id="loginPassword" type="password" autocomplete="current-password" placeholder="Enter your password"> |
| </div> |
| <div class="auth-error" id="loginError"></div> |
| <button type="button" class="auth-submit" id="loginSubmit">Log in</button> |
| </div> |
| </div> |
| |
| <div id="createPane" style="display:none; flex-direction:column; gap:14px;"> |
| <div class="field"><label>Name</label><input id="createName" type="text" autocomplete="name" placeholder="Your name"></div> |
| <div class="field"><label>Email</label><input id="createEmail" type="email" autocomplete="email" placeholder="you@example.com"></div> |
| <div class="field"><label>Password</label><input id="createPassword" type="password" autocomplete="new-password" placeholder="At least 6 characters"></div> |
| <div class="auth-error" id="createError"></div> |
| <button type="button" class="auth-submit" id="createSubmit">Create profile</button> |
| </div> |
| </div> |
| |
| <div class="auth-hint">Each profile's sessions, calibration, and reports are private to that profile — no other profile on this device can see them.</div> |
| |
| <script> |
| __ICONS_JS__ |
| __THEME_JS__ |
| |
| document.getElementById('themeToggle').innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 18); |
| document.getElementById('themeToggle').addEventListener('click', function(){ |
| const next = window.insightuxToggleTheme(); |
| this.innerHTML = iuxIcon(next === 'light' ? 'moon' : 'sun', 18); |
| }); |
| |
| // profile name/email are free text from create_profile() — escape before |
| // innerHTML so a crafted profile name can't run script on this page. |
| function escapeHtml(str){ |
| return String(str).replace(/[&<>"']/g, function(c){ |
| return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; |
| }); |
| } |
| |
| const PROFILES = __PROFILES_JSON__; |
| let selectedProfile = null; |
| |
| function renderProfiles(){ |
| const list = document.getElementById('profileList'); |
| if (!PROFILES.length) { |
| list.innerHTML = '<div class="auth-empty">No profiles yet on this device — create one to get started.</div>'; |
| return; |
| } |
| list.innerHTML = PROFILES.map(function(p, i){ |
| return '<div class="profile-row" data-idx="' + i + '">' + |
| '<span class="avatar">' + escapeHtml(p.avatar) + '</span>' + |
| '<div class="who"><div class="name">' + escapeHtml(p.name) + '</div><div class="email">' + escapeHtml(p.email) + '</div></div>' + |
| '</div>'; |
| }).join(''); |
| Array.from(list.children).forEach(function(row, i){ |
| row.addEventListener('click', function(){ selectProfile(PROFILES[i]); }); |
| }); |
| } |
| renderProfiles(); |
| |
| function selectProfile(p){ |
| selectedProfile = p; |
| document.getElementById('profileListWrap').style.display = 'none'; |
| document.getElementById('passwordWrap').style.display = 'flex'; |
| document.getElementById('passwordForLabel').textContent = 'Password for ' + p.name; |
| const pwInput = document.getElementById('loginPassword'); |
| pwInput.value = ''; |
| document.getElementById('loginError').classList.remove('on'); |
| pwInput.focus(); |
| } |
| document.getElementById('backToList').addEventListener('click', function(){ |
| document.getElementById('passwordWrap').style.display = 'none'; |
| document.getElementById('profileListWrap').style.display = 'block'; |
| selectedProfile = null; |
| }); |
| |
| function showError(id, msg){ |
| const el = document.getElementById(id); |
| el.textContent = msg; |
| el.classList.add('on'); |
| } |
| |
| function doLogin(){ |
| if (!selectedProfile || !window.pywebview) return; |
| const pw = document.getElementById('loginPassword').value; |
| const btn = document.getElementById('loginSubmit'); |
| btn.disabled = true; btn.textContent = 'Signing in...'; |
| window.pywebview.api.login(selectedProfile.id, pw).then(function(res){ |
| if (!res || !res.ok) { |
| btn.disabled = false; btn.textContent = 'Log in'; |
| showError('loginError', (res && res.error) || 'Could not sign in.'); |
| } |
| // On success Python itself navigates the window -- nothing else to do here. |
| }); |
| } |
| document.getElementById('loginSubmit').addEventListener('click', doLogin); |
| document.getElementById('loginPassword').addEventListener('keydown', function(e){ if (e.key === 'Enter') doLogin(); }); |
| |
| function doCreate(){ |
| if (!window.pywebview) return; |
| const name = document.getElementById('createName').value.trim(); |
| const email = document.getElementById('createEmail').value.trim(); |
| const pw = document.getElementById('createPassword').value; |
| const btn = document.getElementById('createSubmit'); |
| btn.disabled = true; btn.textContent = 'Creating...'; |
| window.pywebview.api.create_profile(name, email, pw).then(function(res){ |
| if (!res || !res.ok) { |
| btn.disabled = false; btn.textContent = 'Create profile'; |
| showError('createError', (res && res.error) || 'Could not create profile.'); |
| } |
| }); |
| } |
| document.getElementById('createSubmit').addEventListener('click', doCreate); |
| |
| function showTab(which){ |
| const login = which === 'login'; |
| document.getElementById('tabLogin').classList.toggle('on', login); |
| document.getElementById('tabCreate').classList.toggle('on', !login); |
| document.getElementById('loginPane').style.display = login ? 'block' : 'none'; |
| document.getElementById('createPane').style.display = login ? 'none' : 'flex'; |
| } |
| document.getElementById('tabLogin').addEventListener('click', function(){ showTab('login'); }); |
| document.getElementById('tabCreate').addEventListener('click', function(){ showTab('create'); }); |
| if (!PROFILES.length) showTab('create'); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def _write_login_page(): |
| path = os.path.join(DATA_DIR, "insightux_login.html") |
| html = _LOGIN_HTML |
| html = html.replace("__THEME_CSS__", theme.THEME_CSS) |
| html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS) |
| html = html.replace("__ICONS_JS__", theme.ICONS_JS) |
| html = html.replace("__LOGO_ICON__", theme.icon("eye", 20)) |
| html = html.replace("__PROFILES_JSON__", json.dumps(auth.list_profiles(DATA_DIR))) |
| with open(path, "w", encoding="utf-8") as f: |
| f.write(html) |
| return "file://" + path.replace(os.sep, "/") |
|
|
|
|
| LOGIN_URL = _write_login_page() |
|
|
|
|
| def normalize_pose(pitch_deg, yaw_deg, roll_deg): |
| return np.array([ |
| pitch_deg / POSE_NORM_SCALE, |
| yaw_deg / POSE_NORM_SCALE, |
| roll_deg / POSE_NORM_SCALE, |
| ], dtype=np.float32) |
|
|
| def compensate_pitch(raw_pitch, head_pitch_deg): |
| return raw_pitch - np.radians(head_pitch_deg) * HEAD_PITCH_COMPENSATION |
|
|
| def compensate_yaw(raw_yaw, head_yaw_deg): |
| return raw_yaw - np.radians(head_yaw_deg) * HEAD_YAW_COMPENSATION |
|
|
|
|
| |
| |
| |
|
|
| class OneEuroFilter: |
| def __init__(self, mincutoff=0.8, beta=0.4, dcutoff=1.0): |
| self.mincutoff = mincutoff |
| self.beta = beta |
| self.dcutoff = dcutoff |
| self.x_prev = None |
| self.dx_prev = 0.0 |
| self.t_prev = None |
|
|
| @staticmethod |
| def _alpha(cutoff, dt): |
| tau = 1.0 / (2 * math.pi * cutoff) |
| return 1.0 / (1.0 + tau / dt) |
|
|
| def __call__(self, x, t): |
| if self.x_prev is None: |
| self.x_prev, self.t_prev = x, t |
| return x |
| dt = t - self.t_prev |
| if dt <= 0: |
| dt = 1e-3 |
| self.t_prev = t |
| dx = (x - self.x_prev) / dt |
| a_d = self._alpha(self.dcutoff, dt) |
| dx_hat = a_d * dx + (1 - a_d) * self.dx_prev |
| cutoff = self.mincutoff + self.beta * abs(dx_hat) |
| a = self._alpha(cutoff, dt) |
| x_hat = a * x + (1 - a) * self.x_prev |
| self.x_prev, self.dx_prev = x_hat, dx_hat |
| return x_hat |
|
|
| def reset(self): |
| self.x_prev = None |
| self.dx_prev = 0.0 |
| self.t_prev = None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| CHROME_JS = r""" |
| (function(){ |
| if (window.__insightuxControl) { return; } |
| window.__insightuxControl = true; |
| |
| // Required as the trailing argument on every state-changing/private |
| // window.pywebview.api call below (see _require_token in browser_session.py |
| // for the full reasoning). A plain closure-local const, deliberately never |
| // assigned to `window` — pywebview exposes window.pywebview.api to every |
| // page's own script, not just this injected one, so a hostile website |
| // could otherwise call start_tracking()/delete_my_session()/quit_app() |
| // etc. directly. Because this is a normal JS closure variable, a page's |
| // own top-level script has no way to read it back out — only code that |
| // was actually part of THIS script (baked in by Python at injection |
| // time) can produce a valid call. |
| const __IUX_TOK__ = "__API_TOKEN__"; |
| |
| // S/E (start/stop tracking) only make sense on an actual website — not on |
| // the InsightUX landing/search page and not on a generated insights |
| // report page. The toolbar/sidebar, and X (quit), always work everywhere |
| // except the login page, which has no user/session context yet. |
| const isLanding = !!document.querySelector('meta[name="insightux-landing"]'); |
| const isReport = !!document.querySelector('meta[name="insightux-report"]'); |
| const isLogin = !!document.querySelector('meta[name="insightux-login"]'); |
| const isTrackable = !isLanding && !isReport && !isLogin; |
| |
| __ICONS_JS__ |
| __THEME_JS__ |
| |
| // The report page (isReport) and the login page (isLogin) get none of |
| // this injected chrome — no toolbar, no sidebar, no reserved body |
| // padding — so each has the full window to itself. Only X-quit (further |
| // down, outside this guard) still works there, same as before. |
| if (!isReport && !isLogin) { |
| |
| const style = document.createElement('style'); |
| style.textContent = ` |
| __THEME_CSS__ |
| html { overflow-x: hidden !important; } |
| body { |
| box-sizing: border-box !important; |
| width: 100vw !important; |
| margin: 0 !important; |
| padding-top: 54px !important; |
| padding-left: 78px !important; |
| overflow-x: hidden !important; |
| } |
| #__insightux_toolbar.chrome-hidden { transform: translateY(-130%); } |
| #__insightux_sidebar.chrome-hidden { transform: translateX(-130%); } |
| #__insightux_toolbar { |
| position:fixed; top:0; left:0; right:0; height:54px; z-index:2147483647; |
| display:flex; align-items:center; gap:8px; padding:0 14px; box-sizing:border-box; |
| font:12px var(--iux-font); color:var(--iux-text); |
| border-bottom:1px solid var(--iux-border); |
| transition: transform .25s var(--iux-ease); |
| } |
| #__insightux_toolbar .iux-navbtn { |
| flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0; |
| } |
| /* Tracking mode: shrink the toolbar down to status pills + a small |
| "reveal" handle so the tracked page gets the screen back, without |
| ever removing the controls — __iux_reveal_toggle brings them back. */ |
| #__insightux_toolbar.tracking-compact { height:38px; gap:6px; padding:0 10px; } |
| #__insightux_toolbar.tracking-compact .iux-navbtn, |
| #__insightux_toolbar.tracking-compact #__iux_addr_wrap, |
| #__insightux_toolbar.tracking-compact #__insightux_update, |
| #__insightux_toolbar.tracking-compact #__insightux_status { display:none; } |
| #__insightux_toolbar.tracking-compact #__iux_reveal_toggle { display:flex !important; } |
| body.iux-tracking-compact { padding-top:38px !important; } |
| #__iux_addr_wrap { |
| flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center; |
| } |
| #__iux_addr_wrap .lead-icon { position:absolute; left:12px; color:var(--iux-text-faint); pointer-events:none; display:flex; } |
| #__iux_addr_form { flex:1 1 auto; min-width:0; } |
| #__iux_addr { |
| width:100%; box-sizing:border-box; padding:8px 14px 8px 36px; border-radius:999px; |
| border:1.5px solid var(--iux-border); background:var(--iux-surface); color:var(--iux-text); outline:none; |
| font-size:12.5px; transition:border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease); |
| } |
| #__iux_addr:focus { border-color:var(--iux-primary-light); box-shadow:var(--iux-shadow-glow); } |
| #__iux_loadbar { |
| position:fixed; top:0; left:0; height:2px; width:0; z-index:2147483647; |
| background:var(--iux-accent-grad); transition:width .5s var(--iux-ease), opacity .3s ease .25s; opacity:0; |
| } |
| #__insightux_indicators { display:flex; align-items:center; gap:6px; flex:0 0 auto; } |
| #__insightux_status.iux-pill { max-width:26vw; } |
| #__insightux_status .txt { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| #__iux_theme_toggle { flex:0 0 auto; } |
| |
| #__insightux_sidebar { |
| position:fixed; top:66px; left:12px; bottom:12px; width:66px; z-index:2147483647; |
| display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:12px 8px; |
| box-sizing:border-box; font:11px var(--iux-font); |
| border-radius:var(--iux-radius-lg); |
| transition:width .2s var(--iux-ease), transform .25s var(--iux-ease); |
| } |
| #__insightux_sidebar.collapsed { width:56px; } |
| #__insightux_sidebar .side-btn { |
| position:relative; flex-direction:column; gap:4px; padding:11px 4px; line-height:1.35; |
| font-size:10.5px; width:100%; |
| } |
| #__insightux_sidebar .side-btn.active { |
| background:var(--iux-accent-grad); border-color:transparent; color:var(--iux-on-accent); box-shadow:var(--iux-shadow-glow); |
| } |
| #__insightux_sidebar.collapsed .side-btn .lbl { display:none; } |
| #__insightux_sidebar .side-btn .tip { |
| position:absolute; left:100%; top:50%; transform:translateY(-50%) translateX(8px); |
| background:var(--iux-surface-hi); color:var(--iux-text); border:1px solid var(--iux-border); |
| padding:5px 10px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; |
| opacity:0; transition:opacity .15s ease; box-shadow:var(--iux-shadow-sm); |
| } |
| #__insightux_sidebar.collapsed .side-btn:hover .tip { opacity:1; } |
| #__insightux_sidebar .spacer { margin-top:auto; } |
| #__insightux_sidebar .hints { |
| color:var(--iux-text-faint); font-size:9px; line-height:1.7; text-align:center; padding:2px 0; |
| } |
| #__insightux_sidebar.collapsed .hints { display:none; } |
| #__iux_collapse_btn { width:100%; } |
| |
| #__iux_settings_pop { |
| position:absolute; left:100%; bottom:0; margin-left:10px; width:220px; padding:14px; |
| display:none; z-index:2147483647; border-radius:var(--iux-radius); |
| } |
| #__iux_settings_pop.open { display:block; } |
| #__iux_settings_pop h4 { margin:0 0 10px; font-size:12px; color:var(--iux-text); } |
| #__iux_settings_pop .row { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; font-size:11.5px; color:var(--iux-text-dim); } |
| #__iux_settings_pop .kbd-row { display:flex; justify-content:space-between; font-size:11px; color:var(--iux-text-faint); padding:3px 0; } |
| #__iux_settings_pop .ver { margin-top:8px; font-size:10px; color:var(--iux-text-faint); text-align:center; } |
| |
| #__iux_participant_pop { |
| position:absolute; left:100%; bottom:0; margin-left:10px; width:250px; padding:14px; |
| display:none; z-index:2147483647; border-radius:var(--iux-radius); |
| max-height:min(420px, 80vh); overflow-y:auto; |
| } |
| #__iux_participant_pop.open { display:block; } |
| #__iux_participant_pop h4 { margin:0 0 8px; font-size:12px; color:var(--iux-text); } |
| .iux-subject-list { display:flex; flex-direction:column; gap:2px; margin-bottom:2px; } |
| .iux-subject-row { |
| display:flex; align-items:center; gap:9px; padding:7px 8px; border-radius:8px; cursor:pointer; |
| font-size:12px; color:var(--iux-text-dim); transition:background .12s ease, color .12s ease; |
| } |
| .iux-subject-row:hover { background:var(--iux-surface-hi); } |
| .iux-subject-row.on { color:var(--iux-text); font-weight:600; } |
| .iux-subject-radio { |
| width:14px; height:14px; border-radius:50%; border:1.5px solid var(--iux-border); |
| flex-shrink:0; position:relative; |
| } |
| .iux-subject-row.on .iux-subject-radio { border-color:var(--iux-primary-light); } |
| .iux-subject-row.on .iux-subject-radio::after { |
| content:''; position:absolute; inset:2.5px; border-radius:50%; background:var(--iux-primary-light); |
| } |
| .iux-subject-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| .iux-participant-label { |
| font-size:9.5px; text-transform:uppercase; letter-spacing:.06em; color:var(--iux-text-faint); |
| font-weight:700; margin:8px 2px 4px; |
| } |
| .iux-participant-empty { font-size:11px; color:var(--iux-text-faint); padding:6px 8px; } |
| .iux-participant-back { |
| display:flex; align-items:center; gap:6px; background:transparent; border:none; |
| color:var(--iux-text-faint); font-size:11.5px; cursor:pointer; padding:0; margin-bottom:12px; |
| } |
| .iux-participant-back:hover { color:var(--iux-text); } |
| .iux-participant-error { |
| display:none; font-size:11px; color:var(--iux-danger); background:rgba(251,113,133,0.1); |
| border:1px solid rgba(251,113,133,0.3); border-radius:8px; padding:7px 9px; margin-bottom:10px; |
| } |
| .iux-participant-error.on { display:block; } |
| .iux-field { display:flex; flex-direction:column; gap:4px; margin-bottom:10px; } |
| .iux-field label { |
| font-size:10px; color:var(--iux-text-faint); font-weight:600; |
| text-transform:uppercase; letter-spacing:.04em; |
| } |
| .iux-field input { |
| padding:8px 10px; font-size:12.5px; border-radius:8px; border:1.5px solid var(--iux-border); |
| background:var(--iux-surface); color:var(--iux-text); outline:none; |
| transition:border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease); |
| } |
| .iux-field input:focus { border-color:var(--iux-primary-light); box-shadow:var(--iux-shadow-glow); } |
| .iux-form-row { display:flex; gap:8px; margin-top:4px; } |
| .iux-form-row .iux-btn { flex:1 1 auto; justify-content:center; padding:8px; font-size:11.5px; } |
| .iux-form-row .iux-btn.primary { background:var(--iux-accent-grad); color:var(--iux-on-accent); border-color:transparent; } |
| |
| #__insightux_update { |
| display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap; |
| padding:6px 13px; border-radius:999px; font-weight:600; font-size:11px; |
| background:var(--iux-accent-grad); color:var(--iux-on-accent); align-items:center; gap:6px; |
| } |
| #__insightux_update:hover { transform:translateY(-1px); } |
| |
| /* -- profile avatar + three-dot menu: right end of the toolbar -- */ |
| #__iux_profile_btn { |
| width:32px; height:32px; padding:0; border-radius:50%; flex-shrink:0; |
| background:var(--iux-accent-grad); color:var(--iux-on-accent); |
| font:700 12.5px var(--iux-font); border-color:transparent; |
| } |
| .iux-toolbar-pop { |
| position:absolute; top:calc(100% + 8px); right:0; z-index:2147483647; |
| min-width:230px; padding:8px; border-radius:var(--iux-radius); |
| display:none; flex-direction:column; gap:2px; |
| } |
| .iux-toolbar-pop.open { display:flex; } |
| .iux-profile-head { display:flex; align-items:center; gap:10px; padding:8px 10px 10px; } |
| .iux-profile-avatar { |
| width:34px; height:34px; border-radius:50%; flex-shrink:0; |
| background:var(--iux-accent-grad); color:var(--iux-on-accent); |
| display:flex; align-items:center; justify-content:center; font:700 13px var(--iux-font); |
| } |
| .iux-profile-name { font-size:12.5px; font-weight:600; color:var(--iux-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } |
| .iux-profile-email { font-size:11px; color:var(--iux-text-faint); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } |
| .iux-profile-active { |
| display:flex; align-items:center; gap:6px; padding:0 10px 8px; |
| font-size:10.5px; color:var(--iux-success); font-weight:600; |
| } |
| .iux-menu-sep { height:1px; background:var(--iux-border); margin:4px 6px; } |
| .iux-menu-item { |
| display:flex; align-items:center; gap:8px; width:100%; text-align:left; |
| padding:8px 10px; border-radius:8px; border:none; background:transparent; |
| color:var(--iux-text-dim); font-size:12px; font-family:var(--iux-font); |
| cursor:pointer; transition:background .12s ease, color .12s ease; |
| } |
| .iux-menu-item:hover { background:var(--iux-surface-hi); color:var(--iux-text); } |
| |
| #__iux_info_modal { |
| position:fixed; inset:0; z-index:2147483647; display:none; |
| align-items:center; justify-content:center; background:rgba(0,0,0,0.35); |
| } |
| #__iux_info_modal.open { display:flex; } |
| #__iux_info_modal .box { |
| width:340px; max-width:88vw; padding:20px; border-radius:var(--iux-radius); |
| } |
| #__iux_info_modal h4 { margin:0 0 8px; font-size:14px; color:var(--iux-text); display:flex; align-items:center; gap:8px; } |
| #__iux_info_modal p { margin:0; font-size:12.5px; line-height:1.6; color:var(--iux-text-dim); } |
| #__iux_info_modal .iux-btn { margin-top:14px; width:100%; justify-content:center; } |
| |
| /* -- session history: Profile > My Sessions / Three-dot > Persons -- */ |
| #__iux_sessions_modal { |
| position:fixed; inset:0; z-index:2147483647; display:none; |
| align-items:center; justify-content:center; background:rgba(0,0,0,0.35); |
| } |
| #__iux_sessions_modal.open { display:flex; } |
| #__iux_sessions_modal .box { |
| width:440px; max-width:90vw; max-height:78vh; padding:18px; |
| border-radius:var(--iux-radius); display:flex; flex-direction:column; gap:12px; |
| } |
| .iux-sessions-head { display:flex; align-items:center; gap:8px; flex-shrink:0; } |
| .iux-sessions-head h4 { margin:0; font-size:13.5px; color:var(--iux-text); } |
| .iux-sessions-back { |
| width:26px; height:26px; border-radius:8px; border:1px solid var(--iux-border); |
| background:var(--iux-surface); color:var(--iux-text-dim); cursor:pointer; |
| display:flex; align-items:center; justify-content:center; flex-shrink:0; padding:0; |
| } |
| .iux-sessions-back:hover { background:var(--iux-surface-hi); color:var(--iux-text); } |
| #__iux_sessions_search_wrap { |
| display:none; align-items:center; gap:8px; padding:7px 11px; border-radius:9px; |
| border:1px solid var(--iux-border); background:var(--iux-surface); flex-shrink:0; |
| } |
| #__iux_sessions_search_wrap.on { display:flex; } |
| #__iux_sessions_search_wrap svg { color:var(--iux-text-faint); flex-shrink:0; } |
| #__iux_sessions_search_input { |
| flex:1 1 auto; min-width:0; border:none; outline:none; background:transparent; |
| font-size:12.5px; color:var(--iux-text); font-family:var(--iux-font); |
| } |
| #__iux_sessions_search_input::placeholder { color:var(--iux-text-faint); } |
| #__iux_sessions_body { overflow-y:auto; display:flex; flex-direction:column; gap:8px; min-height:60px; } |
| .iux-session-card, .iux-person-card { |
| display:flex; align-items:center; gap:12px; padding:12px 14px; border-radius:10px; |
| border:1px solid var(--iux-border); background:var(--iux-surface-hi); |
| cursor:pointer; transition:background .12s ease, transform .12s ease; flex-shrink:0; |
| } |
| .iux-session-card:hover, .iux-person-card:hover { background:var(--iux-surface); transform:translateY(-1px); } |
| .iux-session-card .info, .iux-person-card .info { flex:1 1 auto; min-width:0; } |
| .iux-session-card .when, .iux-person-card .when { font-size:12px; font-weight:600; color:var(--iux-text); } |
| .iux-session-card .meta, .iux-person-card .meta { |
| font-size:11px; color:var(--iux-text-faint); margin-top:3px; |
| overflow:hidden; text-overflow:ellipsis; white-space:nowrap; |
| } |
| .iux-session-card .go, .iux-person-card .go { |
| flex-shrink:0; font-size:11px; font-weight:600; color:var(--iux-primary-light); |
| white-space:nowrap; display:flex; align-items:center; gap:4px; |
| } |
| .iux-person-card .avatar { |
| width:32px; height:32px; border-radius:50%; flex-shrink:0; |
| background:var(--iux-accent-grad); color:var(--iux-on-accent); |
| display:flex; align-items:center; justify-content:center; font:700 13px var(--iux-font); |
| } |
| .iux-sessions-empty { font-size:12px; color:var(--iux-text-faint); text-align:center; padding:24px 0; } |
| |
| .iux-session-label-row { display:flex; align-items:center; gap:6px; } |
| .iux-session-edit-btn { |
| flex-shrink:0; width:20px; height:20px; border-radius:6px; display:flex; align-items:center; |
| justify-content:center; color:var(--iux-text-faint); opacity:0; transition:opacity .12s ease, color .12s ease; |
| } |
| .iux-session-card:hover .iux-session-edit-btn, .iux-person-card:hover .iux-session-edit-btn { opacity:1; } |
| .iux-session-edit-btn:hover { color:var(--iux-primary-light); background:var(--iux-surface); } |
| .iux-session-name-edit { display:flex; align-items:center; gap:6px; width:100%; } |
| .iux-session-name-input { |
| flex:1 1 auto; min-width:0; padding:5px 9px; font-size:12px; border-radius:7px; |
| border:1.5px solid var(--iux-primary-light); background:var(--iux-surface); color:var(--iux-text); outline:none; |
| } |
| .iux-session-name-edit .iux-btn { flex-shrink:0; width:22px; height:22px; padding:0; } |
| .iux-person-edit { display:flex; flex-direction:column; gap:6px; width:100%; } |
| .iux-person-edit .iux-session-name-input { width:100%; } |
| .iux-person-edit-actions { display:flex; align-items:center; gap:6px; justify-content:flex-end; } |
| .iux-person-edit-actions .iux-btn { flex-shrink:0; width:22px; height:22px; padding:0; } |
| .iux-session-thumb { |
| flex-shrink:0; width:52px; height:36px; border-radius:7px; object-fit:cover; |
| background:var(--iux-surface); border:1px solid var(--iux-border); |
| } |
| .iux-session-thumb-empty { |
| display:flex; align-items:center; justify-content:center; color:var(--iux-text-faint); |
| } |
| `; |
| document.head.appendChild(style); |
| |
| const loadbar = document.createElement('div'); |
| loadbar.id = '__iux_loadbar'; |
| document.documentElement.appendChild(loadbar); |
| requestAnimationFrame(function(){ |
| loadbar.style.opacity = '1'; loadbar.style.width = '70%'; |
| setTimeout(function(){ loadbar.style.width = '100%'; }, 160); |
| setTimeout(function(){ loadbar.style.opacity = '0'; }, 550); |
| }); |
| |
| const toolbar = document.createElement('div'); |
| toolbar.id = '__insightux_toolbar'; |
| toolbar.className = 'iux-glass'; |
| toolbar.innerHTML = ` |
| <button type="button" class="iux-btn iux-navbtn" id="__iux_back" title="Back">${iuxIcon('arrow-left',15)}</button> |
| <button type="button" class="iux-btn iux-navbtn" id="__iux_fwd" title="Forward">${iuxIcon('arrow-right',15)}</button> |
| <button type="button" class="iux-btn iux-navbtn" id="__iux_reload" title="Reload">${iuxIcon('refresh',15)}</button> |
| <button type="button" class="iux-btn iux-navbtn" id="__iux_home" title="Home">${iuxIcon('home',15)}</button> |
| <div id="__iux_addr_wrap"> |
| <span class="lead-icon" id="__iux_security_icon"></span> |
| <form id="__iux_addr_form"><input id="__iux_addr" type="text" autocomplete="off" |
| placeholder="Search or enter address"></form> |
| </div> |
| <div id="__insightux_indicators" style="display:none;"> |
| <span class="iux-pill" id="__iux_pill_eye"><span class="dot"></span>Eye Tracking</span> |
| <span class="iux-pill" id="__iux_pill_mouse"><span class="dot"></span>Mouse Tracking</span> |
| <span class="iux-pill" id="__iux_pill_time" style="display:none;">${iuxIcon('clock',11)}<span class="txt">00:00</span></span> |
| </div> |
| <span id="__insightux_update" title="Click to open the download page"></span> |
| <span id="__insightux_status" class="iux-pill" style="display:none;"><span class="txt">Press S to start · H heatmap · M panel · X quit</span></span> |
| <button type="button" class="iux-btn" id="__iux_reveal_toggle" title="Show controls" style="width:32px;height:32px;display:none;">${iuxIcon('menu',15)}</button> |
| <button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button> |
| <div style="position:relative;"> |
| <button type="button" class="iux-btn" id="__iux_profile_btn" title="Profile"></button> |
| <div class="iux-toolbar-pop iux-glass" id="__iux_profile_pop"> |
| <div class="iux-profile-head"> |
| <div class="iux-profile-avatar" id="__iux_profile_avatar"></div> |
| <div style="min-width:0;"> |
| <div class="iux-profile-name" id="__iux_profile_name"></div> |
| <div class="iux-profile-email" id="__iux_profile_email"></div> |
| </div> |
| </div> |
| <div class="iux-profile-active">${iuxIcon('check',12)} Active profile</div> |
| <div class="iux-menu-sep"></div> |
| <button type="button" class="iux-menu-item" id="__iux_my_sessions_btn">${iuxIcon('clock',14)} My Sessions</button> |
| <div class="iux-menu-sep"></div> |
| <button type="button" class="iux-menu-item" id="__iux_switch_profile">${iuxIcon('users',14)} Switch profile</button> |
| <button type="button" class="iux-menu-item" id="__iux_logout">${iuxIcon('log-out',14)} Log out</button> |
| </div> |
| </div> |
| <div style="position:relative;"> |
| <button type="button" class="iux-btn iux-navbtn" id="__iux_more_btn" title="More">${iuxIcon('more-vertical',15)}</button> |
| <div class="iux-toolbar-pop iux-glass" id="__iux_more_pop"> |
| <button type="button" class="iux-menu-item" id="__iux_persons_btn">${iuxIcon('users',14)} Persons</button> |
| <div class="iux-menu-sep"></div> |
| <button type="button" class="iux-menu-item" id="__iux_more_help">${iuxIcon('info',14)} Help</button> |
| <button type="button" class="iux-menu-item" id="__iux_more_about">${iuxIcon('eye',14)} About InsightUX</button> |
| </div> |
| </div> |
| `; |
| document.documentElement.appendChild(toolbar); |
| |
| const sidebar = document.createElement('div'); |
| sidebar.id = '__insightux_sidebar'; |
| sidebar.className = 'iux-glass'; |
| sidebar.innerHTML = ` |
| <button type="button" class="iux-btn side-btn" id="__iux_calibrate">${iuxIcon('eye',18)}<span class="lbl">Calibrate</span><span class="tip">Calibrate</span></button> |
| <button type="button" class="iux-btn side-btn" id="__iux_validate">${iuxIcon('target',18)}<span class="lbl">Validate</span><span class="tip">Validate</span></button> |
| <button type="button" class="iux-btn side-btn" id="__iux_session">${iuxIcon('play',18)}<span class="lbl">Start<br>Session</span><span class="tip">Start Session</span></button> |
| <div class="spacer"></div> |
| <div style="position:relative;"> |
| <button type="button" class="iux-btn side-btn" id="__iux_participant_btn">${iuxIcon('users',18)}<span class="lbl">Participant</span><span class="tip">Participant</span></button> |
| <div id="__iux_participant_pop" class="iux-glass iux-fade-in"> |
| <div id="__iux_participant_main"> |
| <h4>Tracking Subject</h4> |
| <div class="iux-subject-list" id="__iux_subject_list"></div> |
| <div class="iux-menu-sep"></div> |
| <div class="iux-participant-label">Participants</div> |
| <div class="iux-subject-list" id="__iux_participant_list"></div> |
| <button type="button" class="iux-menu-item" id="__iux_add_participant_btn">${iuxIcon('plus',14)} Add Participant</button> |
| </div> |
| <div id="__iux_participant_addform" style="display:none;"> |
| <button type="button" class="iux-participant-back" id="__iux_add_participant_back">${iuxIcon('arrow-left',12)} Back</button> |
| <div class="iux-field"><label>Name</label><input id="__iux_add_participant_name" type="text" placeholder="Participant name"></div> |
| <div class="iux-field"><label>Notes (optional)</label><input id="__iux_add_participant_notes" type="text" placeholder="Optional notes"></div> |
| <div class="iux-participant-error" id="__iux_add_participant_error"></div> |
| <div class="iux-form-row"> |
| <button type="button" class="iux-btn" id="__iux_add_participant_cancel">Cancel</button> |
| <button type="button" class="iux-btn primary" id="__iux_add_participant_submit">Add Participant</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div style="position:relative;"> |
| <button type="button" class="iux-btn side-btn" id="__iux_settings_btn">${iuxIcon('settings',18)}<span class="lbl">Settings</span><span class="tip">Settings</span></button> |
| <div id="__iux_settings_pop" class="iux-glass iux-fade-in"> |
| <h4>Shortcuts</h4> |
| <div class="kbd-row"><span>Start / Stop tracking</span><b>S / E</b></div> |
| <div class="kbd-row"><span>Toggle heatmap</span><b>H</b></div> |
| <div class="kbd-row"><span>Mouse panel</span><b>M</b></div> |
| <div class="kbd-row"><span>Quit</span><b>X</b></div> |
| <div class="ver">InsightUX v__INSIGHTUX_VERSION__</div> |
| </div> |
| </div> |
| <button type="button" class="iux-btn" id="__iux_collapse_btn" title="Collapse sidebar">${iuxIcon('chevron-left',14)}</button> |
| <div class="hints">S/E · H · M · X</div> |
| `; |
| document.documentElement.appendChild(sidebar); |
| |
| setInterval(function(){ |
| if (!document.documentElement.contains(toolbar)) document.documentElement.appendChild(toolbar); |
| if (!document.documentElement.contains(sidebar)) document.documentElement.appendChild(sidebar); |
| }, 1000); |
| |
| // -- theme toggle -- |
| const themeBtn = document.getElementById('__iux_theme_toggle'); |
| function paintThemeBtn(){ |
| themeBtn.innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 15); |
| } |
| paintThemeBtn(); |
| themeBtn.addEventListener('click', function(){ window.insightuxToggleTheme(); paintThemeBtn(); }); |
| |
| // -- sidebar collapse (persisted like theme) -- |
| const collapseBtn = document.getElementById('__iux_collapse_btn'); |
| function applyCollapsed(collapsed){ |
| sidebar.classList.toggle('collapsed', collapsed); |
| collapseBtn.innerHTML = iuxIcon(collapsed ? 'chevron-right' : 'chevron-left', 14); |
| } |
| applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true); |
| collapseBtn.addEventListener('click', function(){ |
| const collapsed = !sidebar.classList.contains('collapsed'); |
| applyCollapsed(collapsed); |
| window.insightuxSetPref('sidebarCollapsed', collapsed); |
| }); |
| |
| // -- tracking-mode compact chrome: sidebar + toolbar shrink automatically |
| // once a session starts, so the tracked page gets maximum space, without |
| // ever removing the controls — __iux_reveal_toggle (only shown while |
| // tracking) brings the full toolbar/sidebar back temporarily. -- |
| const revealToggle = document.getElementById('__iux_reveal_toggle'); |
| let isTrackingNow = false; |
| let controlsRevealed = false; |
| function applyTrackingChrome(){ |
| const compact = isTrackingNow && !controlsRevealed; |
| toolbar.classList.toggle('tracking-compact', compact); |
| document.body.classList.toggle('iux-tracking-compact', compact); |
| if (isTrackingNow) { |
| sidebar.classList.toggle('collapsed', !controlsRevealed); |
| } else { |
| applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true); |
| } |
| revealToggle.innerHTML = iuxIcon(controlsRevealed ? 'x' : 'menu', 15); |
| revealToggle.title = controlsRevealed ? 'Hide controls' : 'Show controls'; |
| } |
| revealToggle.addEventListener('click', function(){ |
| controlsRevealed = !controlsRevealed; |
| applyTrackingChrome(); |
| }); |
| |
| // -- settings popover -- |
| const settingsBtn = document.getElementById('__iux_settings_btn'); |
| const settingsPop = document.getElementById('__iux_settings_pop'); |
| settingsBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| participantPop.classList.remove('open'); |
| settingsPop.classList.toggle('open'); |
| }); |
| document.addEventListener('click', function(){ |
| settingsPop.classList.remove('open'); |
| participantPop.classList.remove('open'); |
| }); |
| |
| // -- participant panel ------------------------------------------------ |
| // Tracking subject (Myself vs. a participant) and the participant list |
| // itself both live only in Python (participants.py) — this panel is a |
| // thin view over list_participants()/add_participant()/ |
| // set_tracking_subject(), the same call-Python-then-repaint pattern the |
| // profile popover already uses. |
| const participantBtn = document.getElementById('__iux_participant_btn'); |
| const participantPop = document.getElementById('__iux_participant_pop'); |
| const participantMain = document.getElementById('__iux_participant_main'); |
| const participantAddForm = document.getElementById('__iux_participant_addform'); |
| let PARTICIPANTS_CACHE = []; |
| let ACTIVE_SUBJECT_ID = ''; // '' = Myself |
| |
| participantPop.addEventListener('click', function(e){ e.stopPropagation(); }); |
| |
| participantBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| settingsPop.classList.remove('open'); |
| const wasOpen = participantPop.classList.contains('open'); |
| participantPop.classList.remove('open'); |
| if (!wasOpen) { |
| participantPop.classList.add('open'); |
| showParticipantMain(); |
| refreshParticipantList(); |
| } |
| }); |
| |
| function showParticipantMain(){ |
| participantAddForm.style.display = 'none'; |
| participantMain.style.display = 'block'; |
| } |
| |
| function refreshParticipantList(){ |
| if (!window.pywebview) return; |
| window.pywebview.api.list_participants(__IUX_TOK__).then(function(list){ |
| PARTICIPANTS_CACHE = list || []; |
| renderSubjectList(); |
| }); |
| } |
| |
| function renderSubjectList(){ |
| const wrap = document.getElementById('__iux_subject_list'); |
| wrap.innerHTML = '<div class="iux-subject-row' + (ACTIVE_SUBJECT_ID === '' ? ' on' : '') + '" data-id="">' + |
| '<span class="iux-subject-radio"></span><span class="iux-subject-name">Myself</span></div>'; |
| |
| const plist = document.getElementById('__iux_participant_list'); |
| if (!PARTICIPANTS_CACHE.length) { |
| plist.innerHTML = '<div class="iux-participant-empty">No participants yet.</div>'; |
| } else { |
| plist.innerHTML = PARTICIPANTS_CACHE.map(function(p){ |
| return '<div class="iux-subject-row' + (ACTIVE_SUBJECT_ID === p.id ? ' on' : '') + '" data-id="' + p.id + '">' + |
| '<span class="iux-subject-radio"></span><span class="iux-subject-name">' + escapeHtml(p.name) + '</span></div>'; |
| }).join(''); |
| } |
| |
| Array.from(wrap.children).concat(Array.from(plist.children)).forEach(function(row){ |
| if (!row.dataset || row.dataset.id === undefined) return; |
| row.addEventListener('click', function(){ |
| const id = row.getAttribute('data-id'); |
| if (id === ACTIVE_SUBJECT_ID || !window.pywebview) return; |
| window.pywebview.api.set_tracking_subject(id || null, __IUX_TOK__).then(function(ok){ |
| if (ok) { ACTIVE_SUBJECT_ID = id; renderSubjectList(); } |
| }); |
| }); |
| }); |
| } |
| |
| window.insightuxSetSubject = function(subject){ |
| ACTIVE_SUBJECT_ID = (subject && subject.id) || ''; |
| if (participantPop.classList.contains('open')) renderSubjectList(); |
| }; |
| |
| document.getElementById('__iux_add_participant_btn').addEventListener('click', function(e){ |
| e.stopPropagation(); |
| participantMain.style.display = 'none'; |
| participantAddForm.style.display = 'block'; |
| document.getElementById('__iux_add_participant_error').classList.remove('on'); |
| document.getElementById('__iux_add_participant_name').value = ''; |
| document.getElementById('__iux_add_participant_notes').value = ''; |
| document.getElementById('__iux_add_participant_name').focus(); |
| }); |
| document.getElementById('__iux_add_participant_back').addEventListener('click', function(e){ |
| e.stopPropagation(); |
| showParticipantMain(); |
| }); |
| document.getElementById('__iux_add_participant_cancel').addEventListener('click', function(e){ |
| e.stopPropagation(); |
| showParticipantMain(); |
| }); |
| document.getElementById('__iux_add_participant_submit').addEventListener('click', function(e){ |
| e.stopPropagation(); |
| if (!window.pywebview) return; |
| const name = document.getElementById('__iux_add_participant_name').value.trim(); |
| const notes = document.getElementById('__iux_add_participant_notes').value.trim(); |
| const errEl = document.getElementById('__iux_add_participant_error'); |
| errEl.classList.remove('on'); |
| if (!name) { errEl.textContent = 'Name is required.'; errEl.classList.add('on'); return; } |
| const btn = e.currentTarget; |
| btn.disabled = true; |
| window.pywebview.api.add_participant(name, notes, __IUX_TOK__).then(function(res){ |
| btn.disabled = false; |
| if (res && res.ok) { |
| showParticipantMain(); |
| refreshParticipantList(); |
| } else { |
| errEl.textContent = (res && res.error) || 'Could not add participant.'; |
| errEl.classList.add('on'); |
| } |
| }); |
| }); |
| |
| // -- profile popover + three-dot menu -------------------------------- |
| // Same call-a-Python-method-then-let-it-navigate pattern as every other |
| // sidebar action (__iux_calibrate/__iux_validate/__iux_session above) — |
| // no new plumbing, just two more buttons feeding the existing callApi(). |
| const profileBtn = document.getElementById('__iux_profile_btn'); |
| const profilePop = document.getElementById('__iux_profile_pop'); |
| const moreBtn = document.getElementById('__iux_more_btn'); |
| const morePop = document.getElementById('__iux_more_pop'); |
| |
| function closeAllPops(){ |
| settingsPop.classList.remove('open'); |
| profilePop.classList.remove('open'); |
| morePop.classList.remove('open'); |
| } |
| profileBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| const wasOpen = profilePop.classList.contains('open'); |
| closeAllPops(); |
| if (!wasOpen) profilePop.classList.add('open'); |
| }); |
| moreBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| const wasOpen = morePop.classList.contains('open'); |
| closeAllPops(); |
| if (!wasOpen) morePop.classList.add('open'); |
| }); |
| document.addEventListener('click', closeAllPops); |
| |
| window.insightuxSetProfile = function(profile){ |
| if (!profile || !profile.name) return; |
| profileBtn.textContent = profile.avatar || profile.name.charAt(0).toUpperCase(); |
| document.getElementById('__iux_profile_avatar').textContent = profile.avatar || profile.name.charAt(0).toUpperCase(); |
| document.getElementById('__iux_profile_name').textContent = profile.name; |
| document.getElementById('__iux_profile_email').textContent = profile.email || ''; |
| }; |
| |
| document.getElementById('__iux_switch_profile').addEventListener('click', function(){ |
| closeAllPops(); |
| callApi('switch_profile_screen'); |
| }); |
| document.getElementById('__iux_logout').addEventListener('click', function(){ |
| closeAllPops(); |
| callApi('logout'); |
| }); |
| document.getElementById('__iux_my_sessions_btn').addEventListener('click', function(){ |
| closeAllPops(); |
| openMySessions(); |
| }); |
| |
| // -- Persons (three-dot menu) -- |
| document.getElementById('__iux_persons_btn').addEventListener('click', function(){ |
| closeAllPops(); |
| openPersonsList(); |
| }); |
| |
| // -- session history modal: Profile > My Sessions / Three-dot > Persons |
| // > a specific participant's history. One shared overlay, reused for |
| // all three views (renderSessionList for a flat session list, |
| // renderPersonList for the participant picker) so there's a single |
| // place that knows how to render a session card / a "View Report" link. |
| const sessionsModal = document.createElement('div'); |
| sessionsModal.id = '__iux_sessions_modal'; |
| sessionsModal.innerHTML = ` |
| <div class="box iux-glass iux-fade-in"> |
| <div class="iux-sessions-head"> |
| <button type="button" class="iux-sessions-back" id="__iux_sessions_back" style="display:none;">${iuxIcon('arrow-left',13)}</button> |
| <h4 id="__iux_sessions_title"></h4> |
| <button type="button" class="iux-btn" id="__iux_sessions_close" style="width:28px;height:28px;padding:0;margin-left:auto;">${iuxIcon('x',13)}</button> |
| </div> |
| <div id="__iux_sessions_search_wrap"> |
| ${iuxIcon('search',13)} |
| <input type="text" id="__iux_sessions_search_input" placeholder="Search sessions by name or site" maxlength="100"> |
| </div> |
| <div id="__iux_sessions_body"></div> |
| </div> |
| `; |
| document.documentElement.appendChild(sessionsModal); |
| document.getElementById('__iux_sessions_close').addEventListener('click', function(){ sessionsModal.classList.remove('open'); }); |
| sessionsModal.addEventListener('click', function(e){ if (e.target === sessionsModal) sessionsModal.classList.remove('open'); }); |
| |
| // Client-side only -- the full list for whatever session view is |
| // currently open is already in memory (fetched once by load() in |
| // openMySessions()/openPersonDetails()), so filtering as the user types |
| // needs no round trip to Python. Re-applied after every reload() too |
| // (rename/delete), so the filter text survives an in-place refresh. |
| let __iuxAllSessions = []; |
| let __iuxSessionsCtx = null; |
| const sessionsSearchInput = document.getElementById('__iux_sessions_search_input'); |
| sessionsSearchInput.addEventListener('click', function(e){ e.stopPropagation(); }); |
| sessionsSearchInput.addEventListener('keydown', function(e){ e.stopPropagation(); }); |
| sessionsSearchInput.addEventListener('input', applySessionFilter); |
| |
| function applySessionFilter(){ |
| const q = sessionsSearchInput.value.trim().toLowerCase(); |
| const filtered = !q ? __iuxAllSessions : __iuxAllSessions.filter(function(s){ |
| return (s.customName && s.customName.toLowerCase().includes(q)) || |
| (s.label && s.label.toLowerCase().includes(q)) || |
| (s.domain && s.domain.toLowerCase().includes(q)); |
| }); |
| const emptyMessage = (q && __iuxAllSessions.length) |
| ? 'No sessions match “' + escapeHtml(sessionsSearchInput.value.trim()) + '”.' |
| : undefined; |
| renderSessionList(filtered, __iuxSessionsCtx, emptyMessage); |
| } |
| |
| function openSessionsModal(title, onBack, searchable){ |
| document.getElementById('__iux_sessions_title').textContent = title; |
| const backBtn = document.getElementById('__iux_sessions_back'); |
| backBtn.style.display = onBack ? 'flex' : 'none'; |
| backBtn.onclick = onBack || null; |
| document.getElementById('__iux_sessions_body').innerHTML = '<div class="iux-sessions-empty">Loading…</div>'; |
| sessionsSearchInput.value = ''; |
| document.getElementById('__iux_sessions_search_wrap').classList.toggle('on', !!searchable); |
| __iuxAllSessions = []; |
| __iuxSessionsCtx = null; |
| sessionsModal.classList.add('open'); |
| } |
| |
| // Session rename text is user-typed and, unlike the auto timestamp label, |
| // now persists to disk and is re-rendered on every future visit to this |
| // list — escape it before it goes into innerHTML so a rename can't smuggle |
| // in markup that runs in this window's own script context. |
| function escapeHtml(str){ |
| return String(str).replace(/[&<>"']/g, function(c){ |
| return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; |
| }); |
| } |
| |
| function sessionCardHtml(s, i){ |
| const stats = []; |
| if (s.domain) stats.push(escapeHtml(s.domain)); // domain comes from the tracked page's own URL, not typed by anyone in this app |
| if (s.duration != null) stats.push(s.duration + 's'); |
| if (s.samples != null) stats.push(s.samples + ' gaze samples'); |
| const displayName = escapeHtml(s.customName || s.label); |
| const metaLine = (s.customName ? [escapeHtml(s.label)].concat(stats) : stats).join(' · '); |
| // s.thumbnail is a data: URI built server-side from base64-encoded |
| // image bytes (see _session_thumbnail_data_uri()) -- never user text, |
| // safe to drop straight into the src attribute unescaped. |
| const thumbHtml = s.thumbnail |
| ? '<img class="iux-session-thumb" src="' + s.thumbnail + '" alt="">' |
| : '<span class="iux-session-thumb iux-session-thumb-empty">' + iuxIcon('image', 14) + '</span>'; |
| return '<div class="iux-session-card"' + (s.reportUrl ? '' : ' style="cursor:default;"') + '>' + |
| thumbHtml + |
| '<div class="info">' + |
| '<div class="iux-session-label-row" id="__iux_sess_row' + i + '">' + |
| '<span class="when" id="__iux_sess_label' + i + '">' + displayName + '</span>' + |
| '<button type="button" class="iux-session-edit-btn" id="__iux_sess_edit' + i + '" title="Rename session">' + iuxIcon('type', 11) + '</button>' + |
| '<button type="button" class="iux-session-edit-btn" id="__iux_sess_export' + i + '" title="Export session (JSON + CSV)">' + iuxIcon('download', 11) + '</button>' + |
| '<button type="button" class="iux-session-edit-btn" id="__iux_sess_delete' + i + '" title="Delete session">' + iuxIcon('trash', 11) + '</button>' + |
| '</div>' + |
| (metaLine ? '<div class="meta">' + metaLine + '</div>' : '') + |
| '</div>' + |
| (s.reportUrl ? '<span class="go">View Report ' + iuxIcon('arrow-right', 12) + '</span>' : '') + |
| '</div>'; |
| } |
| |
| // Reused for both My Sessions and a specific participant's history — the |
| // `ctx` (openReport/rename/remove/reload) is what differs between the |
| // two, so this one function knows how to render a session card, open its |
| // report natively (avoids the http(s)->file:// script-navigation block — |
| // see Api._open_session_report()'s own comment), rename it in place, and |
| // delete it. |
| function renderSessionList(sessions, ctx, emptyMessage){ |
| const body = document.getElementById('__iux_sessions_body'); |
| if (!sessions.length) { |
| body.innerHTML = '<div class="iux-sessions-empty">' + (emptyMessage || 'No sessions yet.') + '</div>'; |
| return; |
| } |
| body.innerHTML = sessions.map(function(s, i){ return sessionCardHtml(s, i); }).join(''); |
| Array.from(body.children).forEach(function(card, i){ |
| const s = sessions[i]; |
| if (s.reportUrl) card.addEventListener('click', function(){ ctx.openReport(s.id); }); |
| const editBtn = document.getElementById('__iux_sess_edit' + i); |
| if (editBtn) { |
| editBtn.addEventListener('click', function(e){ e.stopPropagation(); startEditingSessionName(sessions, i, ctx); }); |
| } |
| const exportBtn = document.getElementById('__iux_sess_export' + i); |
| if (exportBtn) { |
| exportBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| exportBtn.style.pointerEvents = 'none'; |
| ctx.export(s.id).then(function(ok){ |
| exportBtn.style.pointerEvents = ''; |
| if (ok) window.insightuxSetStatus && window.insightuxSetStatus('Session exported.'); |
| }); |
| }); |
| } |
| const delBtn = document.getElementById('__iux_sess_delete' + i); |
| if (delBtn) { |
| delBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| if (!confirm('Delete this session and its report? This cannot be undone.')) return; |
| ctx.remove(s.id).then(function(ok){ if (ok) ctx.reload(); }); |
| }); |
| } |
| }); |
| } |
| |
| function startEditingSessionName(sessions, i, ctx){ |
| const s = sessions[i]; |
| const row = document.getElementById('__iux_sess_row' + i); |
| row.innerHTML = |
| '<div class="iux-session-name-edit">' + |
| '<input type="text" class="iux-session-name-input" id="__iux_sess_name_input' + i + '" value="' + |
| escapeHtml(s.customName || '') + '" placeholder="' + escapeHtml(s.label) + '" maxlength="80">' + |
| '<button type="button" class="iux-btn" id="__iux_sess_name_save' + i + '" title="Save">' + iuxIcon('check', 12) + '</button>' + |
| '<button type="button" class="iux-btn" id="__iux_sess_name_cancel' + i + '" title="Cancel">' + iuxIcon('x', 12) + '</button>' + |
| '</div>'; |
| const input = document.getElementById('__iux_sess_name_input' + i); |
| input.addEventListener('click', function(e){ e.stopPropagation(); }); |
| input.focus(); |
| input.select(); |
| |
| function commit(){ ctx.rename(s.id, input.value).then(function(){ ctx.reload(); }); } |
| function cancel(){ ctx.reload(); } |
| |
| document.getElementById('__iux_sess_name_save' + i).addEventListener('click', function(e){ e.stopPropagation(); commit(); }); |
| document.getElementById('__iux_sess_name_cancel' + i).addEventListener('click', function(e){ e.stopPropagation(); cancel(); }); |
| input.addEventListener('keydown', function(e){ |
| e.stopPropagation(); |
| if (e.key === 'Enter') { e.preventDefault(); commit(); } |
| else if (e.key === 'Escape') { e.preventDefault(); cancel(); } |
| }); |
| } |
| |
| function personCardHtml(p, i){ |
| const last = p.last_session_at |
| ? new Date(p.last_session_at).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'}) |
| : 'never'; |
| const count = p.session_count || 0; |
| return '<div class="iux-person-card" data-id="' + p.id + '">' + |
| '<span class="avatar">' + (p.name ? escapeHtml(p.name.charAt(0).toUpperCase()) : '?') + '</span>' + |
| '<div class="info">' + |
| '<div class="iux-session-label-row" id="__iux_person_row' + i + '">' + |
| '<span class="when" id="__iux_person_label' + i + '">' + escapeHtml(p.name) + '</span>' + |
| '<button type="button" class="iux-session-edit-btn" id="__iux_person_edit' + i + '" title="Edit participant">' + iuxIcon('type', 11) + '</button>' + |
| '<button type="button" class="iux-session-edit-btn" id="__iux_person_delete' + i + '" title="Delete participant">' + iuxIcon('trash', 11) + '</button>' + |
| '</div>' + |
| '<div class="meta">' + count + ' tracking session' + (count === 1 ? '' : 's') + ' · Last tracked: ' + last + '</div>' + |
| '</div>' + |
| '<span class="go">' + iuxIcon('arrow-right', 12) + '</span>' + |
| '</div>'; |
| } |
| |
| function renderPersonList(list){ |
| const body = document.getElementById('__iux_sessions_body'); |
| if (!list.length) { |
| body.innerHTML = '<div class="iux-sessions-empty">No participants yet — add one from the Participant panel.</div>'; |
| return; |
| } |
| body.innerHTML = list.map(function(p, i){ return personCardHtml(p, i); }).join(''); |
| Array.from(body.children).forEach(function(card, i){ |
| const p = list[i]; |
| card.addEventListener('click', function(){ openPersonDetails(p); }); |
| const editBtn = document.getElementById('__iux_person_edit' + i); |
| if (editBtn) { |
| editBtn.addEventListener('click', function(e){ e.stopPropagation(); startEditingPerson(list, i); }); |
| } |
| const delBtn = document.getElementById('__iux_person_delete' + i); |
| if (delBtn) { |
| delBtn.addEventListener('click', function(e){ |
| e.stopPropagation(); |
| if (!confirm('Delete ' + p.name + ' and all ' + (p.session_count || 0) + ' of their sessions? This cannot be undone.')) return; |
| window.pywebview.api.delete_participant(p.id, __IUX_TOK__).then(function(res){ |
| if (!(res && res.ok) && res && res.error) window.insightuxSetStatus && window.insightuxSetStatus(res.error); |
| openPersonsList(); |
| }); |
| }); |
| } |
| }); |
| } |
| |
| function startEditingPerson(list, i){ |
| const p = list[i]; |
| const row = document.getElementById('__iux_person_row' + i); |
| row.className = 'iux-person-edit'; |
| row.innerHTML = |
| '<input type="text" class="iux-session-name-input" id="__iux_person_name_input' + i + '" value="' + |
| escapeHtml(p.name) + '" placeholder="Name" maxlength="60">' + |
| '<input type="text" class="iux-session-name-input" id="__iux_person_notes_input' + i + '" value="' + |
| escapeHtml(p.notes || '') + '" placeholder="Notes (optional)" maxlength="200">' + |
| '<div class="iux-person-edit-actions">' + |
| '<button type="button" class="iux-btn" id="__iux_person_save' + i + '" title="Save">' + iuxIcon('check', 12) + '</button>' + |
| '<button type="button" class="iux-btn" id="__iux_person_cancel' + i + '" title="Cancel">' + iuxIcon('x', 12) + '</button>' + |
| '</div>'; |
| const nameInput = document.getElementById('__iux_person_name_input' + i); |
| const notesInput = document.getElementById('__iux_person_notes_input' + i); |
| [nameInput, notesInput].forEach(function(el){ el.addEventListener('click', function(e){ e.stopPropagation(); }); }); |
| nameInput.focus(); |
| nameInput.select(); |
| |
| function commit(){ |
| const name = nameInput.value.trim(); |
| if (!name) { nameInput.focus(); return; } |
| window.pywebview.api.update_participant(p.id, name, notesInput.value, __IUX_TOK__).then(function(res){ |
| if (!(res && res.ok) && res && res.error) window.insightuxSetStatus && window.insightuxSetStatus(res.error); |
| openPersonsList(); |
| }); |
| } |
| function cancel(){ openPersonsList(); } |
| |
| document.getElementById('__iux_person_save' + i).addEventListener('click', function(e){ e.stopPropagation(); commit(); }); |
| document.getElementById('__iux_person_cancel' + i).addEventListener('click', function(e){ e.stopPropagation(); cancel(); }); |
| [nameInput, notesInput].forEach(function(el){ |
| el.addEventListener('keydown', function(e){ |
| e.stopPropagation(); |
| if (e.key === 'Enter') { e.preventDefault(); commit(); } |
| else if (e.key === 'Escape') { e.preventDefault(); cancel(); } |
| }); |
| }); |
| } |
| |
| function openPersonDetails(person){ |
| openSessionsModal(person.name, function(){ openPersonsList(); }, true); |
| if (!window.pywebview) return; |
| const ctx = { |
| openReport: function(id){ window.pywebview.api.open_participant_session_report(person.id, id, __IUX_TOK__); }, |
| rename: function(id, name){ return window.pywebview.api.rename_participant_session(person.id, id, name, __IUX_TOK__); }, |
| remove: function(id){ return window.pywebview.api.delete_participant_session(person.id, id, __IUX_TOK__); }, |
| export: function(id){ return window.pywebview.api.export_participant_session(person.id, id, __IUX_TOK__); }, |
| reload: function(){ load(); } |
| }; |
| function load(){ |
| window.pywebview.api.list_participant_sessions(person.id, __IUX_TOK__).then(function(sessions){ |
| __iuxAllSessions = sessions || []; |
| __iuxSessionsCtx = ctx; |
| applySessionFilter(); |
| }); |
| } |
| load(); |
| } |
| |
| function openPersonsList(){ |
| openSessionsModal('Persons', null, false); |
| if (!window.pywebview) return; |
| window.pywebview.api.list_participants(__IUX_TOK__).then(function(list){ |
| renderPersonList(list || []); |
| }); |
| } |
| |
| function openMySessions(){ |
| openSessionsModal('My Sessions', null, true); |
| if (!window.pywebview) return; |
| const ctx = { |
| openReport: function(id){ window.pywebview.api.open_my_session_report(id, __IUX_TOK__); }, |
| rename: function(id, name){ return window.pywebview.api.rename_my_session(id, name, __IUX_TOK__); }, |
| remove: function(id){ return window.pywebview.api.delete_my_session(id, __IUX_TOK__); }, |
| export: function(id){ return window.pywebview.api.export_my_session(id, __IUX_TOK__); }, |
| reload: function(){ load(); } |
| }; |
| function load(){ |
| window.pywebview.api.list_my_sessions(__IUX_TOK__).then(function(sessions){ |
| __iuxAllSessions = sessions || []; |
| __iuxSessionsCtx = ctx; |
| applySessionFilter(); |
| }); |
| } |
| load(); |
| } |
| |
| const infoModal = document.createElement('div'); |
| infoModal.id = '__iux_info_modal'; |
| infoModal.innerHTML = ` |
| <div class="box iux-glass iux-fade-in"> |
| <h4 id="__iux_info_title"></h4> |
| <p id="__iux_info_body"></p> |
| <button type="button" class="iux-btn" id="__iux_info_close">Close</button> |
| </div> |
| `; |
| document.documentElement.appendChild(infoModal); |
| document.getElementById('__iux_info_close').addEventListener('click', function(){ infoModal.classList.remove('open'); }); |
| infoModal.addEventListener('click', function(e){ if (e.target === infoModal) infoModal.classList.remove('open'); }); |
| |
| function showInfo(icon, title, body){ |
| document.getElementById('__iux_info_title').innerHTML = iuxIcon(icon, 16) + ' ' + title; |
| document.getElementById('__iux_info_body').textContent = body; |
| infoModal.classList.add('open'); |
| } |
| document.getElementById('__iux_more_help').addEventListener('click', function(){ |
| closeAllPops(); |
| showInfo('info', 'Help', 'Press S to start tracking on any website, E to stop. H toggles the attention heatmap, M opens the mouse panel, and X quits InsightUX. Calibrate and Validate live in the left sidebar.'); |
| }); |
| document.getElementById('__iux_more_about').addEventListener('click', function(){ |
| closeAllPops(); |
| showInfo('eye', 'About InsightUX', 'InsightUX v__INSIGHTUX_VERSION__ — AI-powered eye and mouse tracking research browser. Each profile is private: sessions, calibration, and reports are stored separately per profile and never shared with other profiles on this device. Switching profiles or logging out clears the active profile from memory — the next person has to sign in with their own password before any of that data is reachable again.'); |
| }); |
| |
| // -- security icon (padlock for https, globe otherwise) -- |
| document.getElementById('__iux_security_icon').innerHTML = |
| iuxIcon(location.protocol === 'https:' ? 'lock' : 'globe', 14); |
| |
| // -- status text / pills, driven by the same Python calls as before -- |
| const statusEl = () => document.querySelector('#__insightux_status .txt'); |
| window.insightuxSetStatus = function(text){ |
| const el = statusEl(); |
| if (el) el.textContent = text; |
| // Only ever a real event (calibration/validation launch+finish, tracking |
| // start, "wrapping up", or an error) reaches this function — the default |
| // idle hint baked into the HTML never does — so showing the pill here |
| // and nowhere else keeps it hidden during plain idle browsing without |
| // touching any of the calls themselves. |
| const wrap = document.getElementById('__insightux_status'); |
| if (wrap) wrap.style.display = text ? 'inline-flex' : 'none'; |
| }; |
| |
| let sessionStartedAt = null, timerInterval = null; |
| function tickTimer(){ |
| const pill = document.getElementById('__iux_pill_time'); |
| if (!sessionStartedAt) { pill.style.display = 'none'; return; } |
| const diff = Math.floor((Date.now() - sessionStartedAt) / 1000); |
| const m = String(Math.floor(diff / 60)).padStart(2, '0'); |
| const s = String(diff % 60).padStart(2, '0'); |
| pill.querySelector('.txt').textContent = m + ':' + s; |
| pill.style.display = 'inline-flex'; |
| } |
| // startedAtMs is the session's real start time (Api.session_started_at, |
| // fixed once at start_tracking()) — this same function runs again on |
| // every page navigation while tracking is active (on_page_loaded() |
| // re-injects the whole toolbar into a fresh JS context), so it must NOT |
| // just stamp Date.now() every time or the visible timer restarts on |
| // every navigation instead of counting the whole session. |
| window.insightuxSetSessionUI = function(isTracking, startedAtMs){ |
| const btn = document.getElementById('__iux_session'); |
| if (btn) { |
| btn.innerHTML = isTracking |
| ? iuxIcon('stop',18) + '<span class="lbl">Stop<br>Session</span><span class="tip">Stop Session</span>' |
| : iuxIcon('play',18) + '<span class="lbl">Start<br>Session</span><span class="tip">Start Session</span>'; |
| btn.classList.toggle('active', isTracking); |
| } |
| document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking); |
| document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking); |
| const indicators = document.getElementById('__insightux_indicators'); |
| if (indicators) indicators.style.display = isTracking ? 'flex' : 'none'; |
| isTrackingNow = isTracking; |
| controlsRevealed = false; |
| applyTrackingChrome(); |
| if (isTracking) { |
| sessionStartedAt = startedAtMs || Date.now(); |
| if (timerInterval) clearInterval(timerInterval); |
| timerInterval = setInterval(tickTimer, 1000); |
| tickTimer(); |
| } else { |
| sessionStartedAt = null; |
| if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } |
| tickTimer(); |
| } |
| }; |
| window.insightuxShowUpdate = function(version, url){ |
| const el = document.getElementById('__insightux_update'); |
| if (!el) return; |
| el.innerHTML = iuxIcon('download', 12) + '<span>Update available (v' + version + ')</span>'; |
| el.style.display = 'inline-flex'; |
| el.onclick = function(){ |
| if (window.pywebview && window.pywebview.api && window.pywebview.api.open_update_page) { |
| window.pywebview.api.open_update_page(url, __IUX_TOK__); |
| } |
| }; |
| }; |
| |
| // -- address bar / navigation (pure DOM history/location, no Python round-trip) -- |
| const addrInput = document.getElementById('__iux_addr'); |
| addrInput.value = isLanding ? '' : location.href; |
| document.getElementById('__iux_addr_form').addEventListener('submit', function(e){ |
| e.preventDefault(); |
| const raw = addrInput.value.trim(); |
| if (!raw) return; |
| const looksLikeUrl = /^https?:\/\//i.test(raw) || |
| (/^[\w-]+(\.[\w-]+)+([/?#].*)?$/i.test(raw) && !raw.includes(' ')); |
| if (looksLikeUrl) { |
| location.href = raw.startsWith('http') ? raw : ('https://' + raw); |
| } else { |
| location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw); |
| } |
| }); |
| document.getElementById('__iux_back').addEventListener('click', function(){ history.back(); }); |
| document.getElementById('__iux_fwd').addEventListener('click', function(){ history.forward(); }); |
| document.getElementById('__iux_reload').addEventListener('click', function(){ location.reload(); }); |
| |
| // -- sidebar actions (Python decides validity — camera/session conflicts, |
| // wrong page — and reports back through the status text) -- |
| function callApi(method){ |
| if (window.pywebview && window.pywebview.api && window.pywebview.api[method]) { |
| window.pywebview.api[method](__IUX_TOK__); |
| } |
| } |
| // A page loaded over http(s) can't script-navigate itself to a file:// |
| // URL — the browser silently blocks it, no exception, no navigation — |
| // so this has to go through the native window.load_url() call in |
| // Api.go_home() instead of a plain location.href assignment. |
| document.getElementById('__iux_home').addEventListener('click', function(){ callApi('go_home'); }); |
| document.getElementById('__iux_calibrate').addEventListener('click', function(){ callApi('start_calibration'); }); |
| document.getElementById('__iux_validate').addEventListener('click', function(){ callApi('run_validation'); }); |
| document.getElementById('__iux_session').addEventListener('click', function(){ callApi('toggle_session'); }); |
| |
| // -- adaptive visibility while browsing a real website: hide on scroll |
| // down, reveal on scroll up or near the top. Landing page keeps chrome |
| // permanently visible (no listener attached there); report page never |
| // reaches this code at all (whole block is skipped when isReport). |
| if (isTrackable) { |
| let lastScrollY = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; |
| const REVEAL_ZONE = 80, HIDE_DELTA = 24; |
| window.addEventListener('scroll', function(){ |
| const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; |
| const goingDown = y > lastScrollY + HIDE_DELTA; |
| const goingUp = y < lastScrollY - HIDE_DELTA; |
| if (y < REVEAL_ZONE) { |
| toolbar.classList.remove('chrome-hidden'); |
| sidebar.classList.remove('chrome-hidden'); |
| } else if (goingDown) { |
| toolbar.classList.add('chrome-hidden'); |
| sidebar.classList.add('chrome-hidden'); |
| lastScrollY = y; |
| } else if (goingUp) { |
| toolbar.classList.remove('chrome-hidden'); |
| sidebar.classList.remove('chrome-hidden'); |
| lastScrollY = y; |
| } |
| }, { passive: true }); |
| } |
| |
| } // end if (!isReport && !isLogin) |
| |
| function isTypingTarget(el){ |
| if (!el) return false; |
| const tag = el.tagName ? el.tagName.toLowerCase() : ''; |
| return tag === 'input' || tag === 'textarea' || tag === 'select' || |
| el.isContentEditable === true; |
| } |
| |
| document.addEventListener('keydown', function(e){ |
| const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement); |
| const apiReady = !!(window.pywebview && window.pywebview.api); |
| |
| if (e.key === 'x' || e.key === 'X') { |
| if (typingBlocked || !apiReady) return; |
| window.pywebview.api.quit_app(__IUX_TOK__); |
| return; |
| } |
| |
| if (!isTrackable) return; // S/E do nothing on the landing page or a report page |
| |
| if (e.key === 's' || e.key === 'S' || e.key === 'e' || e.key === 'E') { |
| console.log('[insightux] key=' + e.key, 'typingBlocked=' + typingBlocked, 'apiReady=' + apiReady, 'focusedEl=' + (document.activeElement && document.activeElement.tagName)); |
| } |
| if (!apiReady) return; |
| if (typingBlocked) { |
| window.insightuxSetStatus("Click somewhere on the page (not a text field) before pressing S/E"); |
| return; |
| } |
| |
| if (e.key === 's' || e.key === 'S') { |
| window.pywebview.api.start_tracking(__IUX_TOK__); |
| } else if (e.key === 'e' || e.key === 'E') { |
| window.pywebview.api.stop_tracking(__IUX_TOK__); |
| } |
| }, true); // capture phase — fires before page scripts can intercept/stop the event |
| })(); |
| """ |
| CHROME_JS = CHROME_JS.replace("__ICONS_JS__", theme.ICONS_JS) |
| CHROME_JS = CHROME_JS.replace("__THEME_JS__", theme.THEME_TOGGLE_JS) |
| CHROME_JS = CHROME_JS.replace("__THEME_CSS__", theme.THEME_CSS) |
| CHROME_JS = CHROME_JS.replace("__INSIGHTUX_VERSION__", VERSION) |
| CHROME_JS = CHROME_JS.replace("__API_TOKEN__", _API_TOKEN) |
|
|
| |
| |
| TRACKING_JS = r""" |
| (function(){ |
| if (window.__insightux) { return; } |
| const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']); |
| const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel'; |
| const state = { aois: [] }; |
| const dwell = { pendLabel: null, pendSince: 0, activeLabel: null, emptySince: 0 }; |
| const DWELL_MS = 420; |
| const RELEASE_MS = 1200; |
| |
| let activeBox = null; |
| const target = { fx: 0.5, fy: 0.5 }; |
| const dot = { x: null, y: null }; |
| const DOT_LERP = 0.07; |
| |
| const cv = document.createElement('canvas'); |
| cv.id = '__insightux_canvas'; |
| cv.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483646;'; |
| (document.body || document.documentElement).appendChild(cv); |
| const ctx = cv.getContext('2d'); |
| function resize(){ cv.width = window.innerWidth; cv.height = window.innerHeight; } |
| resize(); |
| window.addEventListener('resize', resize, {passive:true}); |
| setInterval(function(){ |
| if (!document.body.contains(cv)) document.body.appendChild(cv); |
| }, 1000); |
| |
| // Momentarily hides InsightUX's own injected UI (toolbar, sidebar, this |
| // gaze/AOI canvas, the mouse HUD panel) so pyautogui's full-screen grab in |
| // maybe_capture_screenshot() captures only the tracked page underneath — |
| // called from Python right before/after each screenshot, never during |
| // normal tracking. |
| window.__insightuxSetCaptureMode = function(hidden){ |
| const vis = hidden ? 'hidden' : ''; |
| cv.style.visibility = vis; |
| ['__insightux_toolbar', '__insightux_sidebar', '__insightux_mouse_panel'].forEach(function(id){ |
| const el = document.getElementById(id); |
| if (el) el.style.visibility = vis; |
| }); |
| }; |
| |
| function roundRectPath(c, x, y, w, h, r){ |
| c.beginPath(); |
| c.moveTo(x + r, y); |
| c.arcTo(x + w, y, x + w, y + h, r); |
| c.arcTo(x + w, y + h, x, y + h, r); |
| c.arcTo(x, y + h, x, y, r); |
| c.arcTo(x, y, x + w, y, r); |
| c.closePath(); |
| } |
| |
| const MIN_W=40, MIN_H=24, MAX_AOIS=90, MAX_SCAN=2500; |
| const PAD_PX = 90; |
| const TALL_WRAPPER_H = 220; |
| |
| const ALWAYS_SELECTOR = "nav, header, footer, img, video, iframe, h1, h2, h3, button, figure, [class*='hero'], [class*='banner'], [class*='card']"; |
| const TEXT_CONTAINER_SELECTOR = "div, span, section, article, main, aside, p, li"; |
| |
| function hasOwnText(el){ |
| for (const node of el.childNodes){ |
| if (node.nodeType === 3 && node.textContent.trim().length > 2) return true; |
| } |
| return false; |
| } |
| |
| function labelFor(el){ |
| if (el.dataset && el.dataset.aoi) return el.dataset.aoi.slice(0,40); |
| const tag = el.tagName.toLowerCase(); |
| if (tag==='nav') return 'navbar'; |
| if (tag==='header') return 'header'; |
| if (tag==='footer') return 'footer'; |
| if (tag==='img'){ |
| const alt=(el.getAttribute('alt')||'').trim(); |
| if (alt) return 'img: '+alt.slice(0,30); |
| const src=el.getAttribute('src')||''; |
| const name=src.split('/').pop().split('?')[0]; |
| return 'img: '+(name||'image').slice(0,30); |
| } |
| if (tag==='video') return 'video'; |
| if (tag==='iframe') return 'embed'; |
| if (tag==='h1'||tag==='h2'){ |
| const t=(el.innerText||'').trim().replace(/\s+/g,' '); |
| if (t) return tag+': '+t.slice(0,30); |
| } |
| const id=el.id?('#'+el.id):''; |
| let cls=''; |
| if (el.className && typeof el.className==='string'){ |
| const f=el.className.trim().split(/\s+/)[0]; |
| if (f) cls='.'+f; |
| } |
| const txt=(el.innerText||'').trim().replace(/\s+/g,' ').slice(0,24); |
| const base=id||cls||tag; |
| return txt?(base+' ('+txt+')'):base; |
| } |
| |
| function refresh(){ |
| const vh = window.innerHeight; |
| const raw = []; |
| const seen = new Set(); |
| let full = false; |
| |
| function consider(el){ |
| if (full) return; |
| // Never track InsightUX's own injected UI (toolbar, sidebar, the |
| // gaze-dot canvas, the mouse tracker panel) as if it were page content. |
| if (OWN_UI_IDS.has(el.id)) return; |
| if (el.closest && el.closest(OWN_UI_SELECTOR)) return; |
| const tag = el.tagName.toLowerCase(); |
| const explicit = !!(el.dataset && el.dataset.aoi); |
| if (!explicit){ |
| const isAlways = (tag==='nav'||tag==='header'||tag==='footer'||tag==='img'|| |
| tag==='video'||tag==='iframe'|| |
| tag==='h1'||tag==='h2'||tag==='h3'||tag==='button'||tag==='figure') || |
| (el.className && typeof el.className==='string' && |
| /hero|banner|card/i.test(el.className)); |
| if (!isAlways && !hasOwnText(el)) return; |
| } |
| const r = el.getBoundingClientRect(); |
| if (r.width<MIN_W || r.height<MIN_H) return; |
| if (r.bottom<0 || r.top>vh) return; |
| const label = labelFor(el); |
| if (!label) return; |
| const key = label+'@'+Math.round(r.left)+','+Math.round(r.top); |
| if (seen.has(key)) return; |
| seen.add(key); |
| const pos = getComputedStyle(el).position; |
| raw.push({el:el, label:label, x:Math.round(r.left), y:Math.round(r.top), |
| w:Math.round(r.width), h:Math.round(r.height), |
| sticky:(pos==='sticky'||pos==='fixed')}); |
| if (raw.length >= MAX_AOIS*2) full = true; |
| } |
| |
| document.querySelectorAll('[data-aoi]').forEach(consider); |
| document.querySelectorAll(ALWAYS_SELECTOR).forEach(consider); |
| |
| const candidates = document.querySelectorAll(TEXT_CONTAINER_SELECTOR); |
| for (let i=0; i<candidates.length && i<MAX_SCAN && !full; i++){ |
| consider(candidates[i]); |
| } |
| |
| const filtered = raw.filter(o => { |
| const isTallWrapper = o.h > TALL_WRAPPER_H && |
| raw.some(o2 => o2 !== o && o.el.contains(o2.el)); |
| return !isTallWrapper; |
| }); |
| |
| state.aois = filtered.slice(0, MAX_AOIS).map(o => ({ |
| label:o.label, x:o.x, y:o.y, w:o.w, h:o.h, sticky:o.sticky |
| })); |
| } |
| refresh(); |
| window.addEventListener('scroll', refresh, {passive:true}); |
| setInterval(refresh, 400); |
| |
| // window.scrollY/document.documentElement.scrollHeight assume <html> is |
| // the element that actually scrolls. Some real-world pages instead put |
| // overflow/height on <body> (or trigger the browser's legacy quirks-mode |
| // scrolling element), which leaves window.scrollY pinned at 0 forever |
| // even while the page visibly scrolls -- the classic documentElement-vs- |
| // body split. Falling back to document.body's own scroll metrics |
| // whenever the documentElement ones read exactly 0 costs nothing on |
| // pages where documentElement was already correct (body's would-be |
| // fallback value only gets used when the primary is falsy) and fixes it |
| // on pages where it wasn't. |
| function scrollPos(){ |
| const sx = window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft || 0; |
| const sy = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; |
| return { x: sx, y: sy }; |
| } |
| function pageSize(){ |
| return { |
| w: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), |
| h: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight), |
| }; |
| } |
| |
| // Elements with position:fixed/sticky stay pinned to the same spot in |
| // every viewport-sized screenshot regardless of scroll depth (a "Brochure" |
| // side tab, a floating CTA, a persistent nav) — the report's full-page |
| // stitcher (analysis.py/buildFullPageStitch) needs to know about them so |
| // it can paste each one only once instead of once per screenshot it was |
| // captured in. AOI candidates (state.aois above) don't cover this: that |
| // list only includes elements matching the attention-worthy-content |
| // heuristics (headings, images, text blocks, ...), so a plain nav tab |
| // never becomes an AOI candidate and its stickiness is never even |
| // evaluated. This is a separate, purpose-built scan, capped by the same |
| // MAX_SCAN budget as the AOI scan above and capped again on result count |
| // since fixed/sticky elements are rare on a normal page. |
| const MAX_STICKY = 20; |
| function stickyRects(){ |
| const vw = window.innerWidth, vh = window.innerHeight; |
| const all = document.querySelectorAll('*'); |
| const out = []; |
| for (let i = 0; i < all.length && i < MAX_SCAN && out.length < MAX_STICKY; i++){ |
| const el = all[i]; |
| if (OWN_UI_IDS.has(el.id) || (el.closest && el.closest(OWN_UI_SELECTOR))) continue; |
| const pos = getComputedStyle(el).position; |
| if (pos !== 'fixed' && pos !== 'sticky') continue; |
| const r = el.getBoundingClientRect(); |
| if (r.width < MIN_W || r.height < MIN_H) continue; |
| if (r.bottom <= 0 || r.top >= vh || r.right <= 0 || r.left >= vw) continue; |
| out.push({ |
| x: Math.round(Math.max(0, r.left)), y: Math.round(Math.max(0, r.top)), |
| w: Math.round(Math.min(vw, r.right) - Math.max(0, r.left)), |
| h: Math.round(Math.min(vh, r.bottom) - Math.max(0, r.top)), |
| }); |
| } |
| return out; |
| } |
| |
| window.insightuxAOIs = function(){ |
| const pos = scrollPos(); |
| const size = pageSize(); |
| return JSON.stringify({ |
| url: location.href, |
| title: document.title || '', |
| scrollX: Math.round(pos.x), |
| scrollY: Math.round(pos.y), |
| viewport:{w:window.innerWidth,h:window.innerHeight}, |
| page:{w:size.w,h:size.h}, |
| aois: state.aois, |
| stickyRects: stickyRects() |
| }); |
| }; |
| |
| window.insightuxUpdate = function(fx, fy){ |
| target.fx = fx; |
| target.fy = fy; |
| }; |
| |
| function renderLoop(){ |
| const w = window.innerWidth, h = window.innerHeight; |
| const tx = target.fx * w, ty = target.fy * h; |
| if (dot.x === null){ dot.x = tx; dot.y = ty; } |
| dot.x += (tx - dot.x) * DOT_LERP; |
| dot.y += (ty - dot.y) * DOT_LERP; |
| |
| const now = performance.now(); |
| const px = dot.x, py = dot.y; |
| |
| let cand = null; |
| for (const a of state.aois){ |
| if (px>=a.x-PAD_PX && px<=a.x+a.w+PAD_PX && py>=a.y-PAD_PX && py<=a.y+a.h+PAD_PX){ |
| if (!cand || (a.w*a.h)<(cand.w*cand.h)) cand = a; |
| } |
| } |
| |
| const candLabel = cand ? cand.label : null; |
| if (candLabel !== dwell.pendLabel){ dwell.pendLabel = candLabel; dwell.pendSince = now; } |
| if (cand){ |
| dwell.emptySince = 0; |
| if (dwell.activeLabel !== cand.label && (now - dwell.pendSince) >= DWELL_MS){ |
| dwell.activeLabel = cand.label; |
| } |
| } else { |
| if (dwell.emptySince === 0) dwell.emptySince = now; |
| if (dwell.activeLabel && (now - dwell.emptySince) >= RELEASE_MS){ |
| dwell.activeLabel = null; |
| } |
| } |
| |
| let active = null; |
| if (dwell.activeLabel){ |
| for (const a of state.aois){ if (a.label === dwell.activeLabel){ active = a; break; } } |
| if (!active) dwell.activeLabel = null; |
| } |
| activeBox = active; |
| |
| ctx.clearRect(0,0,cv.width,cv.height); |
| |
| if (activeBox){ |
| ctx.save(); |
| ctx.fillStyle = 'rgba(255,233,168,0.14)'; |
| ctx.strokeStyle = '#FFE9A8'; ctx.lineWidth = 2.5; |
| roundRectPath(ctx, activeBox.x, activeBox.y, activeBox.w, activeBox.h, 8); |
| ctx.fill(); |
| ctx.stroke(); |
| ctx.restore(); |
| |
| const label = activeBox.label; |
| ctx.font = '600 12px -apple-system, "Segoe UI", Arial'; |
| const tw = ctx.measureText(label).width + 20; |
| const ly = Math.max(0, activeBox.y - 26); |
| ctx.save(); |
| ctx.fillStyle = '#FFE9A8'; |
| roundRectPath(ctx, activeBox.x, ly, tw, 22, 11); |
| ctx.fill(); |
| ctx.fillStyle = '#312F2E'; |
| ctx.fillText(label, activeBox.x + 10, ly + 15); |
| ctx.restore(); |
| } |
| |
| // soft pulsing ring around the dot, on a slow independent cycle |
| const pulse = (Math.sin(now / 420) + 1) / 2; |
| ctx.beginPath(); |
| ctx.arc(dot.x, dot.y, 15 + pulse * 5, 0, 2*Math.PI); |
| ctx.strokeStyle = 'rgba(255,233,168,' + (0.35 - pulse * 0.2) + ')'; |
| ctx.lineWidth = 1.5; |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.arc(dot.x, dot.y, 12, 0, 2*Math.PI); |
| ctx.fillStyle = 'rgba(255,255,255,0.92)'; |
| ctx.fill(); |
| |
| ctx.beginPath(); |
| ctx.arc(dot.x, dot.y, 8, 0, 2*Math.PI); |
| ctx.fillStyle = '#FFE9A8'; |
| ctx.fill(); |
| ctx.lineWidth = 1.5; |
| ctx.strokeStyle = 'rgba(27,25,24,0.55)'; |
| ctx.stroke(); |
| |
| requestAnimationFrame(renderLoop); |
| } |
| requestAnimationFrame(renderLoop); |
| |
| window.__insightux = true; |
| })(); |
| """ |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| MOUSE_JS = r""" |
| (function(){ |
| if (window.__insightuxMouse) { return; } |
| window.__insightuxMouse = true; |
| |
| let isTracking = true; |
| let canvas = null; |
| let panelOpen = false; |
| let logsOpen = false; |
| |
| // Per-sync buffers (flushed to Python every 2s for on-disk persistence) |
| let trailBuffer = []; |
| let heatmapBuffer = []; |
| let clickBuffer = []; |
| let dwellBuffer = []; |
| |
| // Cumulative in-page state (mirrors the extension's background.js state) |
| let allTrail = []; |
| let allHeatmap = []; |
| let allClicks = []; |
| let dwellTotals = {}; |
| |
| let sessionStart = Date.now(); |
| let sessionStop = null; |
| |
| let lastClientX = 0, lastClientY = 0; |
| let lastPageX = 0, lastPageY = 0; |
| let lastSampleTime = 0; |
| const sampleRate = 50; |
| const DWELL_THRESHOLD = 5000; |
| let stationaryStart = 0; |
| let isStationary = false; |
| |
| window.insightuxMouseSetTracking = function(on){ |
| isTracking = !!on; |
| if (isTracking) { |
| sessionStart = Date.now(); |
| sessionStop = null; |
| } else { |
| sessionStop = Date.now(); |
| flushDwell(); |
| } |
| updatePanel(); |
| }; |
| |
| // ---- sampling loop (trail while moving, heatmap dwell points while still) ---- |
| setInterval(function(){ |
| if (!isTracking) return; |
| // Same documentElement-vs-body fallback as TRACKING_JS's scrollPos() |
| // (see its comment) — this loop reconstructs page-absolute coordinates |
| // manually (clientX/Y + scroll offset) instead of using the browser's |
| // own event.pageX/Y (which the click/hover handlers below use and |
| // don't need this), so it needs the same defensive read. |
| const currentScrollX = window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft || 0; |
| const currentScrollY = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; |
| if (lastPageX === 0 && lastPageY === 0 && lastClientX !== 0) { |
| lastPageX = lastClientX + currentScrollX; |
| lastPageY = lastClientY + currentScrollY; |
| } |
| const currentPageX = (lastClientX !== 0) ? (lastClientX + currentScrollX) : lastPageX; |
| const currentPageY = (lastClientY !== 0) ? (lastClientY + currentScrollY) : lastPageY; |
| if (currentPageX === 0 && currentPageY === 0) return; |
| |
| const now = Date.now(); |
| const dt = now - lastSampleTime; |
| if (dt > 1000) { lastSampleTime = now; return; } |
| |
| const dx = currentPageX - lastPageX, dy = currentPageY - lastPageY; |
| const distance = Math.sqrt(dx * dx + dy * dy); |
| |
| if (distance > 2) { |
| const pt = { x: currentPageX, y: currentPageY }; |
| trailBuffer.push(pt); allTrail.push(pt); |
| lastPageX = currentPageX; lastPageY = currentPageY; |
| isStationary = false; stationaryStart = 0; |
| } else { |
| if (!isStationary) { isStationary = true; stationaryStart = now; } |
| else { |
| const dwellDuration = now - stationaryStart; |
| if (dwellDuration > DWELL_THRESHOLD) { |
| const pt = { x: currentPageX, y: currentPageY }; |
| heatmapBuffer.push(pt); allHeatmap.push(pt); |
| } |
| } |
| } |
| lastSampleTime = now; |
| }, sampleRate); |
| |
| // ---- dwell / element-interest tracking ---- |
| let currentHoverLabel = null, currentHoverStartTime = 0; |
| function handleHoverChange(newLabel){ |
| if (!isTracking) return; |
| if (newLabel !== currentHoverLabel) { |
| const now = Date.now(); |
| if (currentHoverLabel && currentHoverStartTime > 0) { |
| const duration = now - currentHoverStartTime; |
| if (duration > 10) recordDwell(currentHoverLabel, duration); |
| } |
| currentHoverLabel = newLabel; |
| currentHoverStartTime = newLabel ? now : 0; |
| } |
| } |
| function recordDwell(element, duration){ |
| dwellBuffer.push({ element: element, duration: duration }); |
| dwellTotals[element] = (dwellTotals[element] || 0) + duration; |
| } |
| function flushDwell(){ |
| if (currentHoverLabel && currentHoverStartTime > 0) { |
| const now = Date.now(); |
| const duration = now - currentHoverStartTime; |
| if (duration > 50) recordDwell(currentHoverLabel, duration); |
| currentHoverStartTime = now; |
| } |
| } |
| |
| // ---- sync to Python -> mouse_log.jsonl in the session folder ---- |
| // Named so the click handler below can call it directly instead of |
| // waiting for the next tick: a click that opens a link navigates |
| // immediately, which tears down this page's JS (and whatever's still |
| // sitting in clickBuffer) before a 2s-only interval would ever get to |
| // send it — the click simply never reaches mouse_log.jsonl. Calling |
| // this right after a click is captured, in addition to the periodic |
| // tick for everything else (trail/heatmap/dwell), closes that race. |
| function syncMouseBatch(){ |
| flushDwell(); |
| if (trailBuffer.length || heatmapBuffer.length || clickBuffer.length || dwellBuffer.length) { |
| const payload = { |
| trail: trailBuffer.length ? trailBuffer : null, |
| heatmap: heatmapBuffer.length ? heatmapBuffer : null, |
| click: clickBuffer.length ? clickBuffer : null, |
| dwell: dwellBuffer.length ? dwellBuffer : null, |
| }; |
| trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = []; |
| if (window.pywebview && window.pywebview.api && window.pywebview.api.log_mouse_data) { |
| try { window.pywebview.api.log_mouse_data(payload).catch(function(){}); } catch (e) {} |
| } |
| } |
| updatePanel(); |
| } |
| setInterval(syncMouseBatch, 2000); |
| |
| // ---- exclude InsightUX's own injected UI from every measurement ---- |
| // (the toolbar, sidebar, mouse-tracker panel, heatmap canvas) — only |
| // real website elements should ever show up in trail/heatmap/dwell/clicks. |
| const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']); |
| const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel'; |
| function isOwnUI(el){ |
| if (!el) return false; |
| if (OWN_UI_IDS.has(el.id)) return true; |
| return !!(el.closest && el.closest(OWN_UI_SELECTOR)); |
| } |
| |
| // ---- mouse position + hover tracking ---- |
| function updateMousePos(e){ |
| if (isOwnUI(e.target)) return; |
| lastClientX = e.clientX; lastClientY = e.clientY; |
| if (lastPageX === 0) lastPageX = e.pageX; |
| if (lastPageY === 0) lastPageY = e.pageY; |
| if (isTracking) handleHoverChange(getSmartLabel(e.target)); |
| } |
| document.addEventListener('mousemove', updateMousePos, true); |
| document.addEventListener('mouseenter', updateMousePos, true); |
| document.addEventListener('mouseover', updateMousePos, true); |
| document.addEventListener('click', updateMousePos, true); |
| document.addEventListener('mouseleave', function(){ |
| lastClientX = 0; lastClientY = 0; |
| handleHoverChange(null); |
| }, true); |
| |
| document.addEventListener('click', function(e){ |
| if (!isTracking) return; |
| if (e.target === canvas) return; |
| if (isOwnUI(e.target)) return; |
| if (!isInteractable(e.target)) return; |
| |
| const x = e.pageX, y = e.pageY; |
| const label = getSmartLabel(e.target) || e.target.tagName; |
| if (['BODY', 'HTML', 'DIV', 'SPAN'].includes(label) && !e.target.innerText.trim()) return; |
| |
| const logEntry = { |
| timestamp: new Date().toLocaleTimeString(), |
| x: x, y: y, element: label, |
| text: e.target.innerText ? e.target.innerText.substring(0, 30).replace(/(\r\n|\n|\r)/gm, ' ').trim() : '', |
| url: window.location.href |
| }; |
| clickBuffer.push(logEntry); allClicks.push(logEntry); |
| if (canvas) drawClick(x, y); |
| syncMouseBatch(); // send immediately — this click may trigger a navigation right after it returns |
| }, true); |
| |
| function isInteractable(el){ |
| if (!el) return false; |
| const tag = el.tagName.toLowerCase(); |
| if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary', 'label'].includes(tag)) return true; |
| const role = el.getAttribute('role'); |
| if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return true; |
| try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch (e) {} |
| let parent = el.parentElement, depth = 0; |
| while (parent && depth < 3) { |
| const pTag = parent.tagName.toLowerCase(); |
| if (['a', 'button'].includes(pTag)) return true; |
| if (parent.getAttribute('role') === 'button') return true; |
| parent = parent.parentElement; depth++; |
| } |
| if (tag === 'code' || tag === 'pre' || el.classList.contains('code') || el.closest('pre')) return true; |
| return false; |
| } |
| |
| function getSmartLabel(el){ |
| if (!el) return null; |
| const tag = el.tagName.toLowerCase(); |
| if (['body', 'html', 'main', 'div', 'span', 'section', 'article'].includes(tag)) { |
| if (el.id && (el.id.includes('logo') || el.id.includes('wrapper') || el.id.includes('container'))) return null; |
| if (el.className && typeof el.className === 'string' && |
| (el.className.toLowerCase().includes('logo') || el.className.toLowerCase().includes('brand'))) return null; |
| const aria = el.getAttribute('aria-label'); |
| if (aria) return 'Element: ' + aria; |
| if (el.children.length === 0) { |
| const txt = el.innerText.trim(); |
| if (txt.length > 2 && txt.length < 50 && /[a-zA-Z0-9]/.test(txt)) return 'Element: ' + txt; |
| } |
| return null; |
| } |
| if (tag === 'a') return 'Link: ' + (el.innerText.trim().substring(0, 30) || 'Link'); |
| if (tag === 'button') return 'Button: ' + (el.innerText.trim().substring(0, 30) || 'Button'); |
| if (tag === 'input') return 'Input: ' + (el.placeholder || el.name || el.id || 'Input'); |
| if (tag === 'textarea') return 'Input: Text Area'; |
| if (tag === 'img') return 'Image: ' + (el.alt || 'Image'); |
| if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) return tag.toUpperCase() + ': ' + el.innerText.trim().substring(0, 40); |
| if (tag === 'code' || tag === 'pre') return 'Code: ' + el.innerText.trim().substring(0, 30); |
| const text = el.innerText.trim(); |
| if (text && text.length > 2) { |
| if (el.classList.contains('code') || el.closest('pre')) return 'Code: ' + text.substring(0, 30); |
| if (text.toLowerCase().includes('no message found')) return null; |
| const isHex = /^#[0-9A-F]{6}$/i.test(text) || /^#[0-9A-F]{3}$/i.test(text); |
| const isColorName = ['red', 'blue', 'green', 'yellow', 'black', 'white', 'orange', 'purple', 'gray', 'grey', 'pink', 'brown', 'cyan', 'magenta'].includes(text.toLowerCase()); |
| if (isHex || isColorName) return null; |
| if (text.length > 50) return 'Text: ' + text.substring(0, 47) + '...'; |
| return 'Text: ' + text; |
| } |
| return null; |
| } |
| |
| // ---- heatmap overlay canvas (ported 1:1 from the extension) ---- |
| let heatOpacity = 0.85, heatIntensity = 0.05; |
| window.insightuxMouseToggleHeatmap = function(){ |
| if (canvas) { document.body.removeChild(canvas); canvas = null; return; } |
| canvas = document.createElement('canvas'); |
| canvas.style.position = 'absolute'; |
| canvas.style.top = '0'; canvas.style.left = '0'; |
| canvas.style.zIndex = '2147483645'; |
| canvas.style.pointerEvents = 'none'; |
| canvas.style.opacity = heatOpacity; |
| canvas.style.transition = 'opacity .15s ease'; |
| canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth); |
| canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight); |
| document.body.appendChild(canvas); |
| drawHeatmapHighQuality(allHeatmap, allTrail, allClicks); |
| }; |
| window.insightuxMouseSetHeatStyle = function(opacity, intensity){ |
| if (opacity != null) { heatOpacity = opacity; if (canvas) canvas.style.opacity = heatOpacity; } |
| if (intensity != null) { heatIntensity = intensity; if (canvas) drawHeatmapHighQuality(allHeatmap, allTrail, allClicks); } |
| }; |
| |
| function drawClick(x, y){ |
| if (!canvas) return; |
| const ctx = canvas.getContext('2d'); |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 3; |
| ctx.shadowColor = 'black'; ctx.shadowBlur = 2; |
| const size = 10; |
| ctx.moveTo(x - size, y - size); ctx.lineTo(x + size, y + size); |
| ctx.moveTo(x + size, y - size); ctx.lineTo(x - size, y + size); |
| ctx.stroke(); |
| ctx.beginPath(); ctx.arc(x, y, size + 5, 0, Math.PI * 2); ctx.stroke(); |
| ctx.restore(); |
| } |
| |
| function drawHeatmapHighQuality(heatmapData, trailData, clickData){ |
| if (!canvas) return; |
| const ctx = canvas.getContext('2d'); |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| let allHeatPoints = []; |
| if (heatmapData && heatmapData.length) allHeatPoints = allHeatPoints.concat(heatmapData); |
| if (trailData && trailData.length) allHeatPoints = allHeatPoints.concat(trailData); |
| if (clickData && clickData.length) { |
| clickData.forEach(function(c){ for (let i = 0; i < 5; i++) allHeatPoints.push({ x: c.x, y: c.y }); }); |
| } |
| if (!allHeatPoints.length) return; |
| const radius = 24; // smaller, more precise spots (was 60) |
| const brushCanvas = document.createElement('canvas'); |
| brushCanvas.width = radius * 2; brushCanvas.height = radius * 2; |
| const brushCtx = brushCanvas.getContext('2d'); |
| const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius); |
| g.addColorStop(0, 'rgba(0, 0, 0, ' + heatIntensity + ')'); g.addColorStop(1, 'rgba(0, 0, 0, 0)'); |
| brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2); |
| allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); }); |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
| const data = imageData.data; |
| const gradientMap = createGradientMap(); |
| for (let i = 0; i < data.length; i += 4) { |
| const alpha = data[i + 3]; |
| if (alpha > 0) { |
| let mapIndex = Math.floor(alpha * 1.5); |
| if (mapIndex > 255) mapIndex = 255; |
| const cIndex = mapIndex * 4; |
| data[i] = gradientMap[cIndex]; data[i + 1] = gradientMap[cIndex + 1]; data[i + 2] = gradientMap[cIndex + 2]; |
| data[i + 3] = Math.min(255, 150 + alpha); |
| } |
| } |
| ctx.putImageData(imageData, 0, 0); |
| } |
| |
| function createGradientMap(){ |
| const c = document.createElement('canvas'); c.width = 256; c.height = 1; |
| const ctx = c.getContext('2d'); |
| const g = ctx.createLinearGradient(0, 0, 256, 0); |
| g.addColorStop(0.0, 'rgba(0, 0, 255, 0)'); |
| g.addColorStop(0.1, 'rgba(0, 0, 255, 1)'); |
| g.addColorStop(0.4, 'rgba(0, 255, 255, 1)'); |
| g.addColorStop(0.6, 'rgba(0, 255, 0, 1)'); |
| g.addColorStop(0.8, 'rgba(255, 255, 0, 1)'); |
| g.addColorStop(1.0, 'rgba(255, 0, 0, 1)'); |
| ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 1); |
| return ctx.getImageData(0, 0, 256, 1).data; |
| } |
| |
| window.addEventListener('resize', function(){ |
| if (canvas) { |
| canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth); |
| canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight); |
| } |
| }); |
| |
| // ---- insights panel: in-page port of the extension's popup.html/popup.js ---- |
| __ICONS_JS__ |
| const style = document.createElement('style'); |
| style.textContent = ` |
| __THEME_CSS__ |
| #__insightux_mouse_panel { |
| position: fixed; top: 66px; right: 16px; width: 268px; z-index: 2147483647; |
| border-radius: var(--iux-radius); box-shadow: var(--iux-shadow); |
| font: 12px var(--iux-font); color: var(--iux-text); padding: 14px; |
| pointer-events: auto; display: none; |
| transform: translateY(-8px); opacity: 0; transition: transform .2s var(--iux-ease), opacity .2s var(--iux-ease); |
| } |
| #__insightux_mouse_panel.open { display: block; } |
| #__insightux_mouse_panel.show { transform: translateY(0); opacity: 1; } |
| #__insightux_mouse_panel h3 { margin: 0 0 10px 0; font-size: 12.5px; color: var(--iux-text); font-weight: 600; |
| display:flex; align-items:center; gap:6px; } |
| #__insightux_mouse_panel .muted { color: var(--iux-text-faint); font-size: 11px; } |
| #__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; } |
| #__insightux_mouse_panel .row .iux-btn { |
| flex: 1 1 auto; padding: 7px 6px; font-size: 10.5px; gap: 4px; |
| } |
| #__insightux_mouse_panel .row .iux-btn.active { background: var(--iux-accent-grad); border-color: transparent; color: var(--iux-on-accent); } |
| #__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 10px; display:flex; align-items:center; gap:6px; color: var(--iux-text-dim); } |
| #__insightux_mouse_panel .hero { |
| background: linear-gradient(135deg, color-mix(in srgb, var(--iux-primary) 16%, transparent), color-mix(in srgb, var(--iux-indigo) 10%, transparent)); |
| border: 1px solid var(--iux-border); border-radius: var(--iux-radius-sm); |
| padding: 10px; text-align: center; margin-bottom: 10px; |
| } |
| #__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: var(--iux-text-faint); letter-spacing: 0.06em; } |
| #__insightux_mouse_panel .hero .el { font-weight: 700; margin: 4px 0; color: var(--iux-primary-light); } |
| #__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item { |
| background: var(--iux-surface-hi); border-radius: 8px; padding: 6px 8px; margin-bottom: 5px; font-size: 11px; |
| } |
| #__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; } |
| #__insightux_mouse_panel .sliders { display: none; margin-bottom: 10px; } |
| #__insightux_mouse_panel .sliders.open { display: block; } |
| #__insightux_mouse_panel .slider-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; font-size: 10.5px; color: var(--iux-text-dim); } |
| #__insightux_mouse_panel .slider-row span { flex: 0 0 52px; } |
| #__insightux_mouse_panel input[type=range] { flex: 1 1 auto; accent-color: var(--iux-primary-light); } |
| `; |
| document.head.appendChild(style); |
| |
| const panel = document.createElement('div'); |
| panel.id = '__insightux_mouse_panel'; |
| panel.className = 'iux-glass'; |
| panel.innerHTML = ` |
| <h3>${iuxIcon('cursor', 14)} Mouse Tracker <span class="muted" id="__mt_status" style="margin-left:auto;">idle</span></h3> |
| <div class="timer">${iuxIcon('clock', 13)} <span id="__mt_timer">00:00</span></div> |
| <div class="row"> |
| <button class="iux-btn" id="__mt_clear" title="Clear collected data">${iuxIcon('trash', 13)} Clear</button> |
| <button class="iux-btn" id="__mt_heatmap" title="Toggle heatmap overlay (H)">${iuxIcon('layers', 13)} Heatmap</button> |
| <button class="iux-btn" id="__mt_logs" title="View click log">${iuxIcon('type', 13)} Logs</button> |
| </div> |
| <div class="sliders" id="__mt_sliders"> |
| <div class="slider-row"><span>Opacity</span><input type="range" id="__mt_opacity" min="20" max="100" value="85"></div> |
| <div class="slider-row"><span>Intensity</span><input type="range" id="__mt_intensity" min="2" max="12" value="5"></div> |
| </div> |
| <div id="__mt_hero" class="hero" style="display:none;"> |
| <div class="lbl">Most Viewed Element</div> |
| <div class="el" id="__mt_hero_el">-</div> |
| <div id="__mt_hero_time">0s</div> |
| </div> |
| <div id="__mt_interests" class="list"></div> |
| <div id="__mt_logs_list" class="list" style="display:none;"></div> |
| `; |
| document.documentElement.appendChild(panel); |
| requestAnimationFrame(function(){ panel.classList.add('show'); }); |
| |
| document.getElementById('__mt_clear').addEventListener('click', function(){ |
| trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = []; |
| allTrail = []; allHeatmap = []; allClicks = []; dwellTotals = {}; |
| sessionStart = Date.now(); sessionStop = null; |
| if (canvas) { document.body.removeChild(canvas); canvas = null; } |
| updatePanel(); |
| }); |
| const heatmapBtn = document.getElementById('__mt_heatmap'); |
| const slidersEl = document.getElementById('__mt_sliders'); |
| heatmapBtn.addEventListener('click', function(){ |
| window.insightuxMouseToggleHeatmap(); |
| heatmapBtn.classList.toggle('active', !!canvas); |
| slidersEl.classList.toggle('open', !!canvas); |
| }); |
| document.getElementById('__mt_opacity').addEventListener('input', function(e){ |
| window.insightuxMouseSetHeatStyle(e.target.value / 100, null); |
| }); |
| document.getElementById('__mt_intensity').addEventListener('input', function(e){ |
| window.insightuxMouseSetHeatStyle(null, e.target.value / 100); |
| }); |
| document.getElementById('__mt_logs').addEventListener('click', function(){ |
| logsOpen = !logsOpen; |
| document.getElementById('__mt_logs').classList.toggle('active', logsOpen); |
| document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none'; |
| document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block'; |
| updatePanel(); |
| }); |
| |
| window.insightuxMouseTogglePanel = function(){ |
| panelOpen = !panelOpen; |
| panel.classList.toggle('open', panelOpen); |
| if (panelOpen) updatePanel(); |
| }; |
| |
| function formatTime(ms){ return Math.round(ms / 1000) + 's'; } |
| |
| function updatePanel(){ |
| if (!panelOpen) return; |
| document.getElementById('__mt_status').textContent = isTracking ? 'tracking' : 'stopped'; |
| const startTs = sessionStart; |
| const diff = isTracking ? Math.floor((Date.now() - startTs) / 1000) |
| : (sessionStop ? Math.floor((sessionStop - startTs) / 1000) : 0); |
| const mins = Math.floor(diff / 60).toString().padStart(2, '0'); |
| const secs = (diff % 60).toString().padStart(2, '0'); |
| document.getElementById('__mt_timer').textContent = 'Session Time: ' + mins + ':' + secs; |
| |
| const interests = Object.entries(dwellTotals).map(function(e){ return { element: e[0], duration: e[1] }; }) |
| .sort(function(a, b){ return b.duration - a.duration; }).slice(0, 6); |
| |
| const hero = document.getElementById('__mt_hero'); |
| if (interests.length) { |
| hero.style.display = 'block'; |
| document.getElementById('__mt_hero_el').textContent = interests[0].element; |
| document.getElementById('__mt_hero_time').textContent = formatTime(interests[0].duration); |
| } else { |
| hero.style.display = 'none'; |
| } |
| |
| const interestsEl = document.getElementById('__mt_interests'); |
| if (!logsOpen) { |
| if (interests.length > 1) { |
| interestsEl.innerHTML = interests.slice(1).map(function(it, i){ |
| return '<div class="interest-item">' + (i + 2) + '. <b>' + it.element + '</b> — ' + formatTime(it.duration) + '</div>'; |
| }).join(''); |
| } else { |
| interestsEl.innerHTML = '<div class="muted">No other interests yet.</div>'; |
| } |
| } |
| |
| const logsEl = document.getElementById('__mt_logs_list'); |
| if (logsOpen) { |
| if (allClicks.length) { |
| logsEl.innerHTML = allClicks.slice().reverse().slice(0, 20).map(function(l){ |
| return '<div class="log-item"><span class="muted">' + l.timestamp + '</span><br><b>' + l.element + '</b><br>"' + l.text + '"</div>'; |
| }).join(''); |
| } else { |
| logsEl.innerHTML = '<div class="muted">No clicks recorded.</div>'; |
| } |
| } |
| } |
| setInterval(updatePanel, 1000); |
| |
| // ---- hotkeys: H = toggle heatmap, M = toggle insights panel ---- |
| function isTypingTarget(el){ |
| if (!el) return false; |
| const tag = el.tagName ? el.tagName.toLowerCase() : ''; |
| return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true; |
| } |
| document.addEventListener('keydown', function(e){ |
| const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement); |
| if (typingBlocked) return; |
| if (e.key === 'h' || e.key === 'H') window.insightuxMouseToggleHeatmap(); |
| else if (e.key === 'm' || e.key === 'M') window.insightuxMouseTogglePanel(); |
| }, true); |
| |
| updatePanel(); |
| })(); |
| """ |
| MOUSE_JS = MOUSE_JS.replace("__ICONS_JS__", theme.ICONS_JS) |
| MOUSE_JS = MOUSE_JS.replace("__THEME_CSS__", theme.THEME_CSS) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| _NON_TRACKABLE_URL_MARKERS = ("insightux_landing.html", "analysis_report.html", "insightux_login.html") |
|
|
|
|
| def _relaunch_args(mode): |
| """Command to spawn calibrate.py/validate.py's logic in a fresh process. |
| Frozen (PyInstaller) builds have no bundled interpreter to hand a .py |
| file to, and sys.executable IS the frozen exe itself — so a frozen build |
| relaunches itself with --mode instead. Unpackaged/dev mode still runs |
| from the venv exactly as before.""" |
| if getattr(sys, "frozen", False): |
| return [sys.executable, "--mode", mode] |
| return [sys.executable, os.path.abspath(__file__), "--mode", mode] |
|
|
|
|
| class Api: |
| def __init__(self): |
| self.window = None |
| self.tracking = False |
| self.session_started_at = None |
| self.current_session_dir = None |
| self.stop_event = threading.Event() |
| self.thread = None |
| self.mouse_log_f = None |
| self.mouse_close_timer = None |
| self.calib_proc = None |
| self.validate_proc = None |
| self.update_info = None |
| self.current_user = None |
| self.active_tracking_subject = None |
|
|
| def _notify_update(self, version, url): |
| try: |
| self.window.evaluate_js( |
| f"window.insightuxShowUpdate && window.insightuxShowUpdate(" |
| f"{json.dumps(version)}, {json.dumps(url)})" |
| ) |
| except Exception: |
| pass |
|
|
| @_require_token |
| def open_update_page(self, url): |
| _log(f"[browser_session] open_update_page() called from JS: {url}") |
| import webbrowser |
| try: |
| webbrowser.open(url) |
| except Exception as e: |
| _log(f"[browser_session] failed to open update url: {e}") |
| return True |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def list_profiles(self): |
| return auth.list_profiles(DATA_DIR) |
|
|
| def login(self, user_id, password): |
| _log(f"[browser_session] login() called from JS for user_id={user_id}") |
| try: |
| profile = auth.verify_login(DATA_DIR, user_id, password) |
| except auth.AuthError as e: |
| _log(f"[browser_session] login failed: {e}") |
| return {"ok": False, "error": str(e)} |
| self._activate_user(profile) |
| return {"ok": True} |
|
|
| def create_profile(self, name, email, password): |
| _log(f"[browser_session] create_profile() called from JS for email={email}") |
| was_first_ever = not auth.load_users(DATA_DIR) |
| try: |
| profile = auth.create_user(DATA_DIR, name, email, password) |
| except auth.AuthError as e: |
| _log(f"[browser_session] create_profile failed: {e}") |
| return {"ok": False, "error": str(e)} |
| if was_first_ever: |
| try: |
| _migrate_legacy_data(profile["id"]) |
| except Exception as e: |
| _log(f"[browser_session] legacy data migration failed (non-fatal): {e}") |
| self._activate_user(profile) |
| return {"ok": True} |
|
|
| def _activate_user(self, profile): |
| """The one place 'current user' and every per-user path flip |
| together, atomically — used by both login() and create_profile() |
| so there's never a window where the paths point at one profile |
| while current_user says another. Every login/switch starts back |
| in "Myself" mode — the previous session's tracking subject (if |
| any) never carries over to a different signed-in owner.""" |
| self.current_user = profile |
| self.active_tracking_subject = None |
| _apply_user_paths(profile["id"]) |
| _apply_subject_paths(None) |
| self._set_profile_ui() |
| self._set_subject_ui() |
| try: |
| fresh_url = _write_landing_page() |
| self.window.load_url(fresh_url) |
| except Exception as e: |
| _log(f"[browser_session] navigating to landing page after login failed: {e}") |
|
|
| @_require_token |
| def switch_profile_screen(self): |
| """'Switch profile' in the profile popover. Waits for any active |
| tracking session to fully finish (report included, same as |
| letting it stop on its own) before showing the picker, so the |
| session Log out/Switch never races the auto-navigate-to-report |
| step already built into gaze_worker().""" |
| _log("[browser_session] switch_profile_screen() called from JS") |
| self._stop_tracking_and_wait() |
| try: |
| fresh_url = _write_login_page() |
| self.window.load_url(fresh_url) |
| except Exception as e: |
| _log(f"[browser_session] switch_profile_screen navigation failed: {e}") |
| return True |
|
|
| @_require_token |
| def logout(self): |
| _log("[browser_session] logout() called from JS") |
| self._stop_tracking_and_wait() |
| self.current_user = None |
| self.active_tracking_subject = None |
| self._clear_user_paths() |
| try: |
| fresh_url = _write_login_page() |
| self.window.load_url(fresh_url) |
| except Exception as e: |
| _log(f"[browser_session] logout navigation failed: {e}") |
| return True |
|
|
| def _stop_tracking_and_wait(self): |
| """Shared by logout()/switch_profile_screen(): stop_tracking() |
| itself only signals the tracking thread and returns immediately |
| (same as pressing E) — join it here so the thread's own |
| report-generation + window navigation (see gaze_worker()) has |
| already happened before we navigate to the login page ourselves, |
| instead of the two racing for the last word on where the window |
| ends up. A bounded timeout keeps logout/switch from ever hanging |
| indefinitely if report generation is unusually slow.""" |
| if not self.tracking: |
| return |
| self.stop_tracking.__wrapped__(self) |
| if self.thread and self.thread.is_alive(): |
| self.thread.join(timeout=8) |
|
|
| def _clear_user_paths(self): |
| """Repoints the per-user constants at a location that's |
| guaranteed never to exist, so that if anything were ever read |
| through them while logged out it fails safe (finds nothing) |
| instead of silently reading whichever profile was active last.""" |
| global SESSIONS_ROOT, CALIBRATION_PATH, CURRENT_USER_DIR, CURRENT_SUBJECT_DIR |
| CURRENT_USER_DIR = None |
| CURRENT_SUBJECT_DIR = None |
| SESSIONS_ROOT = os.path.join(DATA_DIR, "_no_active_profile", "sessions") |
| CALIBRATION_PATH = os.path.join(DATA_DIR, "_no_active_profile", "calibration.pkl") |
|
|
| def _set_profile_ui(self): |
| try: |
| payload = self.current_user or {} |
| self.window.evaluate_js( |
| f"window.insightuxSetProfile && window.insightuxSetProfile({json.dumps(payload)})" |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| @_require_token |
| def list_participants(self): |
| if not CURRENT_USER_DIR: |
| return [] |
| return participants.list_participants(CURRENT_USER_DIR) |
|
|
| @_require_token |
| def add_participant(self, name, notes=""): |
| if not CURRENT_USER_DIR: |
| return {"ok": False, "error": "Sign in first."} |
| try: |
| record = participants.create_participant(CURRENT_USER_DIR, name, notes) |
| except participants.ParticipantError as e: |
| return {"ok": False, "error": str(e)} |
| return {"ok": True, "participant": record} |
|
|
| @_require_token |
| def update_participant(self, participant_id, name, notes=""): |
| if not CURRENT_USER_DIR: |
| return {"ok": False, "error": "Sign in first."} |
| try: |
| record = participants.update_participant(CURRENT_USER_DIR, participant_id, name=name, notes=notes) |
| except participants.ParticipantError as e: |
| return {"ok": False, "error": str(e)} |
| |
| |
| |
| |
| if self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id: |
| self.active_tracking_subject = record |
| self._set_subject_ui() |
| return {"ok": True, "participant": record} |
|
|
| @_require_token |
| def delete_participant(self, participant_id): |
| if not CURRENT_USER_DIR: |
| return {"ok": False, "error": "Sign in first."} |
| if self.tracking and self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id: |
| return {"ok": False, "error": "Stop the current session (E) before deleting this participant."} |
| try: |
| participants.delete_participant(CURRENT_USER_DIR, participant_id) |
| except participants.ParticipantError as e: |
| return {"ok": False, "error": str(e)} |
| if self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id: |
| |
| |
| |
| |
| self.active_tracking_subject = None |
| _apply_subject_paths(None) |
| self._set_subject_ui() |
| return {"ok": True} |
|
|
| @_require_token |
| def set_tracking_subject(self, participant_id): |
| """participant_id falsy -> "Myself". Refuses mid-session, same |
| guard as calibration — switching subjects under a live session |
| would otherwise silently split one session's data across two |
| people's folders.""" |
| _log(f"[browser_session] set_tracking_subject() called from JS: {participant_id!r}") |
| if self.tracking: |
| self._set_status("Stop the current session (E) before changing the tracking subject.") |
| return False |
| if not participant_id: |
| self.active_tracking_subject = None |
| else: |
| record = participants.get_participant(CURRENT_USER_DIR, participant_id) if CURRENT_USER_DIR else None |
| if not record: |
| self._set_status("That participant no longer exists.") |
| return False |
| self.active_tracking_subject = record |
| _apply_subject_paths(self.active_tracking_subject) |
| self._set_subject_ui() |
| who = self.active_tracking_subject["name"] if self.active_tracking_subject else "Myself" |
| self._set_status(f"Now tracking: {who}") |
| return True |
|
|
| def _set_subject_ui(self): |
| try: |
| payload = self.active_tracking_subject |
| self.window.evaluate_js( |
| f"window.insightuxSetSubject && window.insightuxSetSubject({json.dumps(payload)})" |
| ) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| @_require_token |
| def list_my_sessions(self, limit=25): |
| if not CURRENT_USER_DIR: |
| return [] |
| return _sessions_json(os.path.join(CURRENT_USER_DIR, "sessions"), limit) |
|
|
| @_require_token |
| def list_participant_sessions(self, participant_id, limit=25): |
| if not CURRENT_USER_DIR or not participant_id: |
| return [] |
| pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id) |
| return _sessions_json(os.path.join(pdir, "sessions"), limit) |
|
|
| def _resolve_session_dir(self, sessions_root, session_id): |
| """session_id round-trips through JS on every rename/open-report |
| call, and window.pywebview.api is reachable from ANY loaded page's |
| own script (not just our injected chrome) — reject anything that |
| doesn't look like a folder name we ourselves generated before it |
| ever touches os.path.join, instead of trusting it as a path |
| component.""" |
| if not session_id or not _SESSION_ID_RE.match(session_id): |
| return None |
| session_dir = os.path.join(sessions_root, session_id) |
| if not os.path.isdir(session_dir): |
| return None |
| return session_dir |
|
|
| def _open_session_report(self, sessions_root, session_id): |
| """Native window.load_url() call, not page script — mirrors |
| go_home() above: a page loaded over http(s) can't script-navigate |
| itself to a file:// URL (Chromium/WebView2 silently blocks it), so |
| "View Report" from My Sessions/Persons has to come in through here |
| to work while browsing a real website, not just from the landing |
| or report pages (which happen to already be file:// and were |
| masking this).""" |
| session_dir = self._resolve_session_dir(sessions_root, session_id) |
| if not session_dir: |
| return False |
| report_path = os.path.join(session_dir, "analysis_report.html") |
| if not os.path.exists(report_path): |
| return False |
| try: |
| self.window.load_url("file://" + report_path.replace(os.sep, "/")) |
| except Exception as e: |
| _log(f"[browser_session] open session report failed: {e}") |
| return False |
| return True |
|
|
| @_require_token |
| def open_my_session_report(self, session_id): |
| if not CURRENT_USER_DIR: |
| return False |
| return self._open_session_report(os.path.join(CURRENT_USER_DIR, "sessions"), session_id) |
|
|
| @_require_token |
| def open_participant_session_report(self, participant_id, session_id): |
| if not CURRENT_USER_DIR or not participant_id: |
| return False |
| pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id) |
| return self._open_session_report(os.path.join(pdir, "sessions"), session_id) |
|
|
| def _export_session(self, sessions_root, session_id): |
| """Bundles a JSON summary + a CSV of raw gaze samples into a single |
| .zip (one native Save dialog, one resulting file, rather than |
| making the researcher pick a destination twice or hunt for two |
| loose files afterwards) and writes it wherever the user chooses. |
| Read-only against the session itself -- never touches session_dir.""" |
| session_dir = self._resolve_session_dir(sessions_root, session_id) |
| if not session_dir: |
| return False |
| try: |
| gaze, dom = load_session(session_dir) |
| summary = session_summary(gaze, dom) |
| mouse = summarize_mouse(session_dir) |
| except Exception as e: |
| _log(f"[browser_session] export: could not load session data: {e}") |
| return False |
|
|
| meta = {} |
| meta_path = os.path.join(session_dir, "session_meta.json") |
| if os.path.exists(meta_path): |
| try: |
| with open(meta_path, "r", encoding="utf-8") as f: |
| meta = json.load(f) |
| except Exception: |
| meta = {} |
|
|
| summary_json = { |
| "session_id": session_id, |
| "display_name": meta.get("display_name"), |
| "started_at": meta.get("started_at"), |
| "tracking_mode": meta.get("tracking_mode", "self"), |
| "participant_name": meta.get("participant_name"), |
| "url": summary["url"], |
| "duration_seconds": summary["duration"], |
| "gaze_samples": summary["samples"], |
| "mouse_clicks": mouse["click_count"], |
| "mouse_trail_points": mouse["trail_points"], |
| "mouse_heatmap_points": mouse["heatmap_points"], |
| "top_dwell_elements": mouse["interests"], |
| } |
|
|
| try: |
| |
| |
| |
| result = self.window.create_file_dialog( |
| webview.SAVE_DIALOG, directory="", |
| save_filename=f"insightux_session_{session_id}.zip", |
| file_types=("Zip files (*.zip)",), |
| ) |
| except Exception as e: |
| _log(f"[browser_session] export: save dialog failed: {e}") |
| return False |
| if not result: |
| return False |
| |
| |
| |
| |
| |
| |
| |
| dest_path = result if isinstance(result, str) else result[0] |
| if not dest_path.lower().endswith(".zip"): |
| dest_path += ".zip" |
|
|
| try: |
| with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf: |
| zf.writestr("session_summary.json", json.dumps(summary_json, indent=2)) |
| csv_buf = io.StringIO() |
| writer = csv.writer(csv_buf) |
| writer.writerow(["timestamp_seconds", "screen_x", "screen_y"]) |
| for g in gaze: |
| writer.writerow([g.get("t"), g.get("sx"), g.get("sy")]) |
| zf.writestr("gaze_samples.csv", csv_buf.getvalue()) |
| except Exception as e: |
| _log(f"[browser_session] export: failed to write {dest_path}: {e}") |
| return False |
|
|
| _log(f"[browser_session] exported session {session_id} -> {dest_path}") |
| return True |
|
|
| @_require_token |
| def export_my_session(self, session_id): |
| if not CURRENT_USER_DIR: |
| return False |
| return self._export_session(os.path.join(CURRENT_USER_DIR, "sessions"), session_id) |
|
|
| @_require_token |
| def export_participant_session(self, participant_id, session_id): |
| if not CURRENT_USER_DIR or not participant_id: |
| return False |
| pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id) |
| return self._export_session(os.path.join(pdir, "sessions"), session_id) |
|
|
| def _set_session_display_name(self, sessions_root, session_id, name): |
| """Persisted to that session's own session_meta.json (as |
| display_name) instead of browser localStorage — localStorage is |
| scoped per web origin, so a name saved while looking at one |
| website would never show up while looking at another, or from the |
| file:// landing page. A file next to the session's own data is the |
| one place every view of it (My Sessions, Persons, the report |
| itself) can agree on. Sessions from before session_meta.json |
| existed just get a fresh one with only this field.""" |
| session_dir = self._resolve_session_dir(sessions_root, session_id) |
| if not session_dir: |
| return False |
| meta_path = os.path.join(session_dir, "session_meta.json") |
| meta = {} |
| if os.path.exists(meta_path): |
| try: |
| with open(meta_path, "r", encoding="utf-8") as f: |
| meta = json.load(f) |
| except Exception: |
| meta = {} |
| trimmed = (name or "").strip() |
| if trimmed: |
| meta["display_name"] = trimmed |
| else: |
| meta.pop("display_name", None) |
| try: |
| with open(meta_path, "w", encoding="utf-8") as f: |
| json.dump(meta, f, indent=2) |
| except Exception as e: |
| _log(f"[browser_session] could not save session display name: {e}") |
| return False |
| return True |
|
|
| @_require_token |
| def rename_my_session(self, session_id, name): |
| if not CURRENT_USER_DIR: |
| return False |
| return self._set_session_display_name(os.path.join(CURRENT_USER_DIR, "sessions"), session_id, name) |
|
|
| @_require_token |
| def rename_participant_session(self, participant_id, session_id, name): |
| if not CURRENT_USER_DIR or not participant_id: |
| return False |
| pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id) |
| return self._set_session_display_name(os.path.join(pdir, "sessions"), session_id, name) |
|
|
| def _delete_session(self, sessions_root, session_id): |
| """Irreversible; the caller is expected to have already confirmed |
| with the user. Refuses the one folder gaze_worker() might still |
| have file handles open on (self.current_session_dir) — rmtree'ing |
| a session mid-recording would race its own logger/screenshot |
| writes instead of just deleting old, already-closed data.""" |
| session_dir = self._resolve_session_dir(sessions_root, session_id) |
| if not session_dir: |
| return False |
| if self.current_session_dir and os.path.normcase(os.path.normpath(session_dir)) == \ |
| os.path.normcase(os.path.normpath(self.current_session_dir)): |
| return False |
| import shutil |
| try: |
| shutil.rmtree(session_dir) |
| except Exception as e: |
| _log(f"[browser_session] could not delete session: {e}") |
| return False |
| return True |
|
|
| @_require_token |
| def delete_my_session(self, session_id): |
| if not CURRENT_USER_DIR: |
| return False |
| return self._delete_session(os.path.join(CURRENT_USER_DIR, "sessions"), session_id) |
|
|
| @_require_token |
| def delete_participant_session(self, participant_id, session_id): |
| if not CURRENT_USER_DIR or not participant_id: |
| return False |
| pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id) |
| return self._delete_session(os.path.join(pdir, "sessions"), session_id) |
|
|
| @_require_token |
| def toggle_session(self): |
| """Single entry point for the sidebar's Start/Stop Session button — |
| lets the JS button stay dumb (always call the same thing) while |
| Python decides which action makes sense from current state. |
| Calls start_tracking()/stop_tracking() via .__wrapped__ (the |
| original undecorated function functools.wraps preserves) rather |
| than through self.start_tracking()/self.stop_tracking() directly — |
| those are themselves @_require_token, which expects the token as |
| the caller's last positional arg. This internal call passes none, |
| so going through the normal bound method would always be rejected |
| by that same decorator, silently no-op'ing the button.""" |
| return self.stop_tracking.__wrapped__(self) if self.tracking else self.start_tracking.__wrapped__(self) |
|
|
| @_require_token |
| def start_tracking(self): |
| _log("[browser_session] start_tracking() called from JS") |
| if self.tracking: |
| _log("[browser_session] already tracking, ignoring") |
| return False |
| if self.current_user is None: |
| _log("[browser_session] refusing to start, no profile signed in") |
| self._set_status("Sign in to a profile before starting a session.") |
| return False |
| try: |
| current_url = self.window.get_current_url() or "" |
| except Exception: |
| current_url = "" |
| if any(marker in current_url for marker in _NON_TRACKABLE_URL_MARKERS): |
| _log(f"[browser_session] refusing to start on non-website page: {current_url}") |
| self._set_status("Start tracking only works on a website — search or open a page first.") |
| return False |
| if self._external_proc_running(): |
| _log("[browser_session] refusing to start, calibration/validation still running") |
| self._set_status("Wait for calibration/validation to finish before starting a session.") |
| return False |
| if not os.path.exists(CALIBRATION_PATH): |
| _log(f"[browser_session] no {CALIBRATION_PATH} found — run calibrate.py first") |
| self._set_status("No calibration.pkl found — run calibrate.py first.") |
| return False |
|
|
| self.tracking = True |
| self.session_started_at = time.time() |
| self.stop_event.clear() |
| session_dir = os.path.join(SESSIONS_ROOT, datetime.now().strftime("%Y%m%d_%H%M%S")) |
| os.makedirs(session_dir, exist_ok=True) |
| self.current_session_dir = session_dir |
| _log(f"[browser_session] starting tracking thread -> {session_dir}") |
| self._write_session_meta(session_dir) |
|
|
| self._inject_tracking_overlay() |
| self._inject_mouse_overlay() |
| self._open_mouse_log(session_dir) |
| self._set_mouse_tracking(True) |
| self._set_session_ui(True) |
| who = self.active_tracking_subject["name"] if self.active_tracking_subject else "yourself" |
| self._set_status(f"Recording {who} — gaze + mouse... Press E to stop, H for heatmap, M for mouse panel") |
|
|
| self.thread = threading.Thread( |
| target=gaze_worker, args=(self.window, self.stop_event, session_dir, self), |
| daemon=True |
| ) |
| self.thread.start() |
| return True |
|
|
| def _write_session_meta(self, session_dir): |
| """Ownership is recorded up front, at the moment session_dir is |
| created — never reconstructed after the fact from which folder a |
| session happens to be found under. website_url/duration are |
| deliberately NOT duplicated here: analysis.py already derives |
| those from gaze_log.jsonl/dom_log.jsonl, and this file only holds |
| what isn't already derivable — who owns the session and who was |
| being tracked.""" |
| subject = self.active_tracking_subject |
| meta = { |
| "owner_id": self.current_user["id"], |
| "tracking_mode": "participant" if subject else "self", |
| "participant_id": subject["id"] if subject else None, |
| "participant_name": subject["name"] if subject else None, |
| "started_at": datetime.now().isoformat(), |
| } |
| try: |
| with open(os.path.join(session_dir, "session_meta.json"), "w", encoding="utf-8") as f: |
| json.dump(meta, f, indent=2) |
| except Exception as e: |
| _log(f"[browser_session] could not write session_meta.json: {e}") |
| if subject: |
| try: |
| participants.touch_session_stats(CURRENT_USER_DIR, subject["id"]) |
| except Exception as e: |
| _log(f"[browser_session] could not update participant stats: {e}") |
|
|
| @_require_token |
| def stop_tracking(self): |
| _log("[browser_session] stop_tracking() called from JS") |
| if not self.tracking: |
| _log("[browser_session] not currently tracking, ignoring") |
| return False |
| self._set_status("Wrapping up your session...") |
| self._set_mouse_tracking(False) |
| self._set_session_ui(False) |
| self.session_started_at = None |
| self.stop_event.set() |
|
|
| |
| |
| if self.mouse_close_timer: |
| self.mouse_close_timer.cancel() |
| self.mouse_close_timer = threading.Timer(2.5, self._close_mouse_log) |
| self.mouse_close_timer.daemon = True |
| self.mouse_close_timer.start() |
| return True |
|
|
| @_require_token |
| def quit_app(self): |
| |
| |
| _log("[browser_session] quit_app() called from JS -- terminating process") |
| os._exit(0) |
|
|
| @_require_token |
| def go_home(self): |
| |
| |
| |
| |
| |
| |
| _log("[browser_session] go_home() called from JS") |
| try: |
| |
| |
| |
| |
| |
| |
| fresh_url = _write_landing_page() |
| self.window.load_url(fresh_url) |
| except Exception as e: |
| _log(f"[browser_session] go_home failed: {e}") |
| return True |
|
|
| |
| |
| |
| |
| |
| |
|
|
| def _external_proc_running(self): |
| running = lambda p: p is not None and p.poll() is None |
| return running(self.calib_proc) or running(self.validate_proc) |
|
|
| def _subprocess_user_env(self): |
| """calibrate.py/validate.py run as separate processes and compute |
| their own DATA_DIR independently (see their own module docstrings) |
| — this env var is the only channel that tells them which subject's |
| folder to read/write, and which per-subject path fine-tuning |
| should save its adapted model to instead of overwriting the |
| shared bundled ONNX everyone's inference loads. Deliberately |
| CURRENT_SUBJECT_DIR, not CURRENT_USER_DIR: calibrating while |
| "Rahul" is the active tracking subject must calibrate Rahul, not |
| the signed-in owner. Absent (dev/standalone `python calibrate.py`), |
| both scripts fall back to today's exact behavior — see their own |
| INSIGHTUX_USER_DATA_DIR checks.""" |
| env = dict(os.environ) |
| if CURRENT_SUBJECT_DIR: |
| env["INSIGHTUX_USER_DATA_DIR"] = CURRENT_SUBJECT_DIR |
| env["INSIGHTUX_USER_ONNX_OUT"] = os.path.join(CURRENT_SUBJECT_DIR, "gaze_cnn_v4_finetuned.onnx") |
| return env |
|
|
| @_require_token |
| def start_calibration(self): |
| _log("[browser_session] start_calibration() called from JS") |
| if self.current_user is None: |
| self._set_status("Sign in to a profile before calibrating.") |
| return False |
| if self.tracking: |
| self._set_status("Stop the current session (E) before calibrating.") |
| return False |
| if self._external_proc_running(): |
| self._set_status("Calibration/validation is already running in another window.") |
| return False |
| try: |
| self.calib_proc = subprocess.Popen( |
| _relaunch_args("calibrate"), cwd=BASE_DIR, env=self._subprocess_user_env() |
| ) |
| except Exception as e: |
| _log(f"[browser_session] failed to launch calibrate.py: {e}") |
| self._set_status("Could not launch calibration — see terminal for details.") |
| return False |
| self._set_status("Calibration launched in a separate window — follow the dots there.") |
| threading.Thread( |
| target=self._watch_external_proc, args=(self.calib_proc, "Calibration"), daemon=True |
| ).start() |
| return True |
|
|
| @_require_token |
| def run_validation(self): |
| _log("[browser_session] run_validation() called from JS") |
| if self.current_user is None: |
| self._set_status("Sign in to a profile before validating.") |
| return False |
| if self.tracking: |
| self._set_status("Stop the current session (E) before validating.") |
| return False |
| if self._external_proc_running(): |
| self._set_status("Calibration/validation is already running in another window.") |
| return False |
| if not os.path.exists(CALIBRATION_PATH): |
| self._set_status("No calibration.pkl found — run Calibrate first.") |
| return False |
| try: |
| self.validate_proc = subprocess.Popen( |
| _relaunch_args("validate"), cwd=BASE_DIR, env=self._subprocess_user_env() |
| ) |
| except Exception as e: |
| _log(f"[browser_session] failed to launch validate.py: {e}") |
| self._set_status("Could not launch validation — see terminal for details.") |
| return False |
| self._set_status("Validation launched in a separate window — look at each dot.") |
| threading.Thread( |
| target=self._watch_external_proc, args=(self.validate_proc, "Validation"), daemon=True |
| ).start() |
| return True |
|
|
| def _watch_external_proc(self, proc, label): |
| proc.wait() |
| _log(f"[browser_session] {label} process exited (code {proc.returncode})") |
| if label == "Calibration": |
| self._set_status("Calibration finished — check the terminal for the quality readout, then press S or Start Session.") |
| else: |
| self._set_status("Validation finished — check the terminal for the accuracy numbers.") |
|
|
| def _set_session_ui(self, is_tracking): |
| try: |
| flag = "true" if is_tracking else "false" |
| |
| |
| |
| |
| |
| |
| started_ms = ( |
| int(self.session_started_at * 1000) |
| if (is_tracking and self.session_started_at) else "null" |
| ) |
| self.window.evaluate_js( |
| f"window.insightuxSetSessionUI && window.insightuxSetSessionUI({flag}, {started_ms})" |
| ) |
| except Exception: |
| pass |
|
|
| def _inject_tracking_overlay(self): |
| try: |
| self.window.evaluate_js(TRACKING_JS) |
| except Exception as e: |
| _log(f"[browser_session] overlay inject failed: {e}") |
|
|
| def _inject_mouse_overlay(self): |
| try: |
| self.window.evaluate_js(MOUSE_JS) |
| except Exception as e: |
| _log(f"[browser_session] mouse overlay inject failed: {e}") |
|
|
| def _set_mouse_tracking(self, on): |
| try: |
| flag = "true" if on else "false" |
| self.window.evaluate_js( |
| f"window.insightuxMouseSetTracking && window.insightuxMouseSetTracking({flag})" |
| ) |
| except Exception: |
| pass |
|
|
| def _open_mouse_log(self, session_dir): |
| self._close_mouse_log() |
| path = os.path.join(session_dir, "mouse_log.jsonl") |
| try: |
| self.mouse_log_f = open(path, "w", buffering=1) |
| self.mouse_log_f.write(json.dumps({"type": "meta", "t": round(time.time(), 4)}) + "\n") |
| _log(f"[browser_session] writing mouse stream to {path}") |
| except Exception as e: |
| _log(f"[browser_session] could not open mouse log: {e}") |
| self.mouse_log_f = None |
|
|
| def _close_mouse_log(self): |
| if self.mouse_log_f: |
| try: |
| self.mouse_log_f.close() |
| _log("[browser_session] mouse log closed") |
| except Exception: |
| pass |
| self.mouse_log_f = None |
|
|
| def log_mouse_data(self, payload): |
| """Called from the injected Mouse Tracker overlay (MOUSE_JS) roughly |
| every 2s while tracking, with a batch of trail / heatmap / click / |
| dwell records — same cadence as the extension's background.js sync |
| loop, just persisted to mouse_log.jsonl instead of chrome.storage. |
| |
| Deliberately NOT @_require_token: MOUSE_JS is injected as its own |
| separate evaluate_js() call (_inject_mouse_overlay()), a different |
| top-level script from CHROME_JS with its own closure, so it has no |
| way to see CHROME_JS's __IUX_TOK__ constant without either being |
| merged into the same script or having the token attached to |
| `window` — the latter would defeat the whole point, since any |
| page's own script could then read it too. Accepted gap: this |
| already no-ops (returns False) whenever self.mouse_log_f is closed, |
| i.e. outside a session the user themselves started, so the worst |
| case is a hostile page's script injecting bogus rows into the |
| mouse log of a session already running — a data-integrity nuisance |
| confined to that one file, not a way to start recording, read |
| other sessions, or touch anything else.""" |
| if not self.mouse_log_f: |
| return False |
| try: |
| rec = dict(payload) if isinstance(payload, dict) else {} |
| rec["type"] = "mouse_batch" |
| rec["t"] = round(time.time(), 4) |
| self.mouse_log_f.write(json.dumps(rec) + "\n") |
| except Exception as e: |
| _log(f"[browser_session] mouse log write failed: {e}") |
| return True |
|
|
| def _set_status(self, text): |
| try: |
| self.window.evaluate_js(f"window.insightuxSetStatus && window.insightuxSetStatus({json.dumps(text)})") |
| except Exception: |
| pass |
|
|
|
|
| api = Api() |
|
|
|
|
| def _version_tuple(v): |
| return tuple(int(p) for p in v.split(".") if p.isdigit()) |
|
|
|
|
| def check_for_update(): |
| """Runs once in a background thread at startup. Publishing a new release |
| is still fully manual (bump VERSION here + AppVersion in installer.iss + |
| version.json, rebuild, push) — this only automates the *checking* side, |
| per the confirmed scope. No internet / any failure here is silent: |
| the user just doesn't see an update banner, never an error.""" |
| import urllib.request |
| try: |
| with urllib.request.urlopen(VERSION_CHECK_URL, timeout=4) as resp: |
| data = json.loads(resp.read().decode("utf-8")) |
| latest = data.get("latest", "") |
| url = data.get("url", "") |
| if latest and url and _version_tuple(latest) > _version_tuple(VERSION): |
| api.update_info = {"version": latest, "url": url} |
| api._notify_update(latest, url) |
| _log(f"[browser_session] update available: {VERSION} -> {latest}") |
| except Exception as e: |
| _log(f"[browser_session] update check skipped: {e}") |
|
|
|
|
| def on_page_loaded(window): |
| """Fires on every page load (navigation, back/forward, reload — not just |
| the first page). The chrome must be re-injected every time since each |
| navigation is a fresh document. If a session is already running, the |
| gaze/mouse overlays need the same treatment or navigating mid-session |
| would silently kill the dot and the mouse tracker on the new page.""" |
| try: |
| window.evaluate_js(CHROME_JS) |
| except Exception as e: |
| _log(f"[browser_session] chrome inject failed (will retry): {e}") |
|
|
| if api.tracking: |
| api._inject_tracking_overlay() |
| api._inject_mouse_overlay() |
| api._set_mouse_tracking(True) |
| api._set_session_ui(True) |
|
|
| |
| |
| |
| if api.current_user: |
| api._set_profile_ui() |
| api._set_subject_ui() |
|
|
| |
| |
| if api.update_info: |
| api._notify_update(api.update_info["version"], api.update_info["url"]) |
|
|
|
|
| |
| |
| |
|
|
| def gaze_worker(window, stop_event, session_dir, api_ref): |
| pipeline = InsightUXPipeline(_user_onnx_path(), CALIBRATION_PATH) |
| face_mesh = create_face_mesh(static_image_mode=False) |
| cap = cv2.VideoCapture(0) |
| if not cap.isOpened(): |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| cap.release() |
| _log("[browser_session] could not open the camera (in use by another app, no camera present, or permission denied)") |
| api_ref._set_status( |
| "Could not access the camera. Check that it isn't already in use by " |
| "another app and that InsightUX has camera permission, then press S to try again." |
| ) |
| api_ref.tracking = False |
| api_ref.current_session_dir = None |
| api_ref._set_mouse_tracking(False) |
| api_ref._set_session_ui(False) |
| api_ref._close_mouse_log() |
| import shutil |
| shutil.rmtree(session_dir, ignore_errors=True) |
| return |
| logger = GazeLogger(SCREEN_W, SCREEN_H, session_dir=session_dir) |
| dom_path = os.path.join(session_dir, "dom_log.jsonl") |
| dom_f = open(dom_path, "w", buffering=1) |
|
|
| screens_dir = os.path.join(session_dir, "screenshots") |
| os.makedirs(screens_dir, exist_ok=True) |
| shot_count = 0 |
| last_shot_scroll = None |
| last_shot_time = 0.0 |
| SCROLL_SHOT_THRESHOLD = 60 |
| MAX_SHOT_INTERVAL = 2.5 |
|
|
| def maybe_capture_screenshot(scroll_y): |
| """Grab a full-screen shot when the page has scrolled enough, or |
| periodically even if it hasn't — this is what lets the report show |
| the heatmap ON TOP of the actual page instead of floating in a void.""" |
| nonlocal shot_count, last_shot_scroll, last_shot_time |
| now = time.time() |
| scrolled_enough = last_shot_scroll is None or abs(scroll_y - last_shot_scroll) >= SCROLL_SHOT_THRESHOLD |
| time_elapsed = (now - last_shot_time) >= MAX_SHOT_INTERVAL |
| if not (scrolled_enough or time_elapsed): |
| return None |
| try: |
| try: |
| window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(true)") |
| except Exception: |
| pass |
| time.sleep(0.05) |
| try: |
| img = pyautogui.screenshot() |
| finally: |
| try: |
| window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(false)") |
| except Exception: |
| pass |
| |
| |
| |
| |
| if img.size != (SCREEN_W, SCREEN_H): |
| img = img.resize((SCREEN_W, SCREEN_H)) |
| shot_name = f"shot_{shot_count:04d}.png" |
| img.save(os.path.join(screens_dir, shot_name)) |
| shot_count += 1 |
| last_shot_scroll = scroll_y |
| last_shot_time = now |
| return f"screenshots/{shot_name}" |
| except Exception as e: |
| _log(f"[browser_session] screenshot capture failed: {e}") |
| return None |
|
|
| _log(f"[browser_session] tracking started -> {session_dir}") |
|
|
| cam_matrix = None |
| last_x = SCREEN_W / 2 |
| last_y = SCREEN_H / 2 |
| fil_x = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA) |
| fil_y = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA) |
| angle_smoother = GazeAngleSmoother(window=ANGLE_SMOOTH_WINDOW) |
| sm_pitch = sm_yaw = sm_roll = None |
| no_face_count = 0 |
| frame_i = 0 |
|
|
| while not stop_event.is_set(): |
| ret, frame = cap.read() |
| if not ret: |
| break |
|
|
| if cam_matrix is None: |
| cam_matrix = estimate_camera_matrix(frame.shape) |
|
|
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) |
| results = face_mesh.process(rgb) |
|
|
| if not results.multi_face_landmarks: |
| no_face_count += 1 |
| if no_face_count >= NO_FACE_RESET: |
| last_x, last_y = SCREEN_W / 2, SCREEN_H / 2 |
| fil_x.reset(); fil_y.reset() |
| angle_smoother.reset() |
| sm_pitch = sm_yaw = sm_roll = None |
| no_face_count = 0 |
| continue |
| no_face_count = 0 |
|
|
| lms = results.multi_face_landmarks[0].landmark |
| head_pose = estimate_head_pose(lms, frame.shape, cam_matrix) |
| if head_pose is None: |
| continue |
|
|
| if sm_pitch is None: |
| sm_pitch, sm_yaw, sm_roll = head_pose.pitch, head_pose.yaw, head_pose.roll |
| else: |
| b = POSE_SMOOTH |
| sm_pitch = b*sm_pitch + (1-b)*head_pose.pitch |
| sm_yaw = b*sm_yaw + (1-b)*head_pose.yaw |
| sm_roll = b*sm_roll + (1-b)*head_pose.roll |
| head_pose = replace(head_pose, pitch=sm_pitch, yaw=sm_yaw, roll=sm_roll) |
|
|
| pose_vec = normalize_pose(sm_pitch, sm_yaw, sm_roll) |
|
|
| |
| |
| ear_now = 0.5 * (compute_ear(lms, LEFT_EAR_INDICES, frame.shape) + |
| compute_ear(lms, RIGHT_EAR_INDICES, frame.shape)) |
|
|
| def get_patch(eye_idx, ear_idx, iris_idx): |
| s1 = step1_normalize(frame, lms, head_pose, eye_idx, ear_idx, iris_idx) |
| if not s1.is_open: |
| return None |
| if PATCH_SOURCE == "norm": |
| return s1.norm_crop |
| ir = compute_iris_radius(lms, iris_idx, frame.shape) |
| s2 = step2_illumination(s1, ir) |
| return s2.blended if s2.is_usable else None |
|
|
| left_patch = get_patch(LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES) |
| right_patch = get_patch(RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES) |
| if left_patch is None and right_patch is None: |
| continue |
| if left_patch is None: left_patch = right_patch |
| if right_patch is None: right_patch = left_patch |
|
|
| _, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(left_patch, pose_vec, right_patch) |
| pitch = compensate_pitch(raw_pitch, sm_pitch) |
| yaw = compensate_yaw(raw_yaw, sm_yaw) |
|
|
| |
| |
| |
| |
| pitch, yaw, ear_s = angle_smoother(pitch, yaw, ear_now) |
|
|
| sx, sy = pipeline.calibration.predict(pitch, yaw, ear_s) |
| sx = max(0.0, min(sx, SCREEN_W)) |
| sy = max(0.0, min(sy, SCREEN_H)) |
|
|
| jump = np.hypot(sx - last_x, sy - last_y) |
| if jump > MAX_JUMP: |
| sx = last_x + (sx - last_x) * 0.3 |
| sy = last_y + (sy - last_y) * 0.3 |
| last_x, last_y = sx, sy |
|
|
| logger.log(sx, sy) |
|
|
| now = time.time() |
| fx = fil_x(sx / SCREEN_W, now) |
| fy = fil_y(sy / SCREEN_H, now) |
| try: |
| window.evaluate_js(f"window.insightuxUpdate({fx:.5f},{fy:.5f})") |
| except Exception: |
| pass |
|
|
| frame_i += 1 |
| if frame_i % DOM_EVERY == 0: |
| try: |
| raw = window.evaluate_js("window.insightuxAOIs()") |
| if raw: |
| rec = json.loads(raw) |
| rec["type"] = "dom" |
| rec["t"] = round(time.time(), 4) |
| shot_ref = maybe_capture_screenshot(rec.get("scrollY", 0)) |
| if shot_ref: |
| rec["screenshot"] = shot_ref |
| dom_f.write(json.dumps(rec) + "\n") |
| except Exception: |
| pass |
|
|
| time.sleep(0.005) |
|
|
| cap.release() |
| logger.close() |
| dom_f.close() |
| _log(f"[browser_session] session saved in {session_dir}") |
|
|
| |
| try: |
| report_path = generate_report(session_dir) |
| api_ref.tracking = False |
| api_ref.current_session_dir = None |
| window.load_url("file://" + report_path) |
| _log(f"[browser_session] report ready -> {report_path}") |
| except Exception as e: |
| _log(f"[browser_session] report generation failed: {e}") |
| api_ref.tracking = False |
| api_ref.current_session_dir = None |
|
|
|
|
| |
| |
| |
|
|
| def _go_fullscreen(window): |
| |
| |
| |
| |
| |
| |
| time.sleep(0.4) |
| try: |
| window.toggle_fullscreen() |
| except Exception as e: |
| _log(f"[browser_session] fullscreen toggle failed: {e}") |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| |
| import argparse |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--mode", choices=["browser", "calibrate", "validate"], default="browser") |
| args = parser.parse_args() |
|
|
| if args.mode == "calibrate": |
| import calibrate |
| calibrate.main() |
| elif args.mode == "validate": |
| import validate |
| validate.main() |
| else: |
| |
| |
| |
| |
| |
| window = webview.create_window( |
| "InsightUX — Eye-Tracking Research Browser", _write_login_page(), |
| width=SCREEN_W, height=SCREEN_H, js_api=api |
| ) |
| api.window = window |
| window.events.loaded += lambda: on_page_loaded(window) |
| window.events.shown += lambda: threading.Thread( |
| target=_go_fullscreen, args=(window,), daemon=True |
| ).start() |
| threading.Thread(target=check_for_update, daemon=True).start() |
|
|
| |
| |
| |
| |
| webview.start(debug=True) |