Download browser_session.py from InsightUX/Insight_UX_1.0: direct link, hf CLI and curl.
- Browser
- Download file 193 kB
-
https://huggingface.co/InsightUX/Insight_UX_1.0/resolve/main/browser_session.py
- Command line
-
hf download hf://InsightUX/Insight_UX_1.0/browser_session.py
-
curl -L -o browser_session.py https://huggingface.co/InsightUX/Insight_UX_1.0/resolve/main/browser_session.py
193 kB
| """ | |
| 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 | |
| # Windows consoles default to a non-UTF-8 codepage (cp1252) — a stray | |
| # unicode character in any print() (ours, or one of calibrate.py/validate.py's | |
| # once relaunched as a subprocess sharing this process's stdout handle) would | |
| # otherwise crash the whole process with UnicodeEncodeError. Confirmed this | |
| # happened for real during a calibration run mid-session. | |
| 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 | |
| # ============================================================================= | |
| # CONFIG — kept identical to run_session.py so calibration.pkl stays valid | |
| # ============================================================================= | |
| # Bundled read-only assets (models/checkpoints) vs. per-user writable data | |
| # (calibration.pkl, sessions/, the generated landing page) need different | |
| # roots once frozen by PyInstaller — onedir nests bundled data under | |
| # _internal/, alongside a persistent folder holding InsightUX.exe itself. | |
| 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 # kept as an alias: subprocess cwd for calibrate/validate relaunches | |
| # ============================================================================= | |
| # LOGGING — every "[browser_session] ..." diagnostic below goes through | |
| # _log() instead of a bare print(): still prints to the console exactly as | |
| # before (nothing lost for interactive `python browser_session.py` use), | |
| # but is now ALSO durable in DATA_DIR/logs/insightux.log (rotated at 2MB, | |
| # 3 backups kept) so a crash report has something to inspect afterwards | |
| # instead of only whatever was still in a terminal's scrollback. | |
| # ============================================================================= | |
| _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 # this is the only handler we want -- don't also fall through to the root logger's default stderr handler | |
| _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 # a full/unwritable disk shouldn't take the console message down with it | |
| # Bump alongside packaging/installer.iss's AppVersion on every release, and | |
| # update version.json in the same commit — see packaging/README.md. | |
| 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 | |
| # Rolling-median window applied to (pitch, yaw) BEFORE the RBF query. | |
| # See GazeAngleSmoother in inference_pipeline.py for why this is essential | |
| # and not merely a nicety. Must match validate.py to stay comparable. | |
| ANGLE_SMOOTH_WINDOW = 10 | |
| POSE_NORM_SCALE = 30.0 | |
| HEAD_PITCH_COMPENSATION = 0.0 # reverted — 0.35 made accuracy worse, not better | |
| HEAD_YAW_COMPENSATION = 0.0 | |
| SESSIONS_ROOT = os.path.join(DATA_DIR, "sessions") | |
| # Every session folder is named by browser_session.py itself in this exact | |
| # shape (see start_tracking()'s datetime.now().strftime("%Y%m%d_%H%M%S")) — | |
| # used to validate session ids handed back from JS (rename/open-report calls) | |
| # before joining them onto a filesystem path, since window.pywebview.api is | |
| # reachable from any page's own script, not just our injected chrome. | |
| _SESSION_ID_RE = re.compile(r"^\d{8}_\d{6}$") | |
| # pywebview binds js_api to the whole window, not per-frame -- every page | |
| # the user browses (not just our own landing/report/login pages) gets a | |
| # live window.pywebview.api, so a real website's OWN script could otherwise | |
| # call start_tracking()/delete_my_session()/quit_app()/etc. directly, no | |
| # click of ours involved. A per-origin allowlist isn't possible here (the | |
| # app's whole point is that tracking must work on arbitrary real websites, | |
| # so "which site is loaded" can't be the gate) -- instead every state- | |
| # changing Api method below requires this token as its trailing argument. | |
| # It's baked into CHROME_JS as a closure-local const (see __IUX_TOK__ in | |
| # CHROME_JS below) that's never assigned to `window`, so a hosting page's | |
| # own top-level script has no way to read it back out and forge a call. | |
| # Regenerated fresh every process start; never needs to survive a restart. | |
| _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.""" | |
| 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 | |
| # ============================================================================= | |
| # MULTI-USER PROFILES | |
| # | |
| # CALIBRATION_PATH/SESSIONS_ROOT above were, before profiles existed, the | |
| # one-and-only global data locations — and on an install that already has | |
| # data there, they still describe exactly where it lives. _apply_user_paths() | |
| # below repoints these same two names at a per-profile folder the moment | |
| # someone logs in, and every existing call site (gaze_worker(), | |
| # _sessions_json(), the calibration-exists check in start_tracking(), | |
| # the os.makedirs at boot, ...) keeps reading them completely unchanged — | |
| # isolation is a side effect of *which folder these constants point to*, | |
| # not a rewrite of the code that uses them. | |
| # ============================================================================= | |
| _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 # the signed-in owner's own folder; None until login/switch/create | |
| CURRENT_SUBJECT_DIR = None # the ACTIVE TRACKING SUBJECT's folder — owner's own dir in "self" | |
| # mode, or users/<owner>/participants/<id>/ when tracking a | |
| # participant. SESSIONS_ROOT/CALIBRATION_PATH/_user_onnx_path() | |
| # all derive from this one value, never from CURRENT_USER_DIR | |
| # directly, so "who owns this account" and "who is being tracked | |
| # right now" stay two separately-settable things. | |
| 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) | |
| # The window opens on this local, fully InsightUX-branded page instead of | |
| # raw google.com. What you type here still hits REAL Google — no fake | |
| # results, no scraping — this page is just the visible skin around that | |
| # one redirect step. Typed URLs go straight there instead. | |
| _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): | |
| # A report can fail to build once (e.g. a transient issue right | |
| # as tracking stopped) even though the underlying session data | |
| # is completely intact — self-heal here instead of leaving that | |
| # session permanently unreachable. Reuses the exact same | |
| # generate_report() call gaze_worker() already makes at session | |
| # end; no new report-building logic. | |
| 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 # malformed/partial session folder — still list it, just without stats | |
| 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 # no session_meta.json (legacy session) or unreadable — no custom name | |
| thumbnail = _session_thumbnail_data_uri(full) | |
| out.append({ | |
| "id": name, # stable session identifier (its own timestamp folder name) — used to key rename/report calls, never shown directly | |
| "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 / PROFILE PICKER — the window opens HERE, not on LANDING_URL, so a | |
| # password is always required before any profile's data can be reached. | |
| # No login state is persisted across app restarts. | |
| # ============================================================================= | |
| _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 | |
| # ============================================================================= | |
| # ONE EURO FILTER (display smoothing only) | |
| # ============================================================================= | |
| 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 | |
| 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 | |
| # ============================================================================= | |
| # JS: browser chrome (always present) — top toolbar (back/forward/reload/ | |
| # home/address bar) + left sidebar (Calibrate/Validate/Start Session), so the | |
| # window reads as a normal browser instead of a bare content pane. Purely an | |
| # HTML/CSS/JS overlay drawn on top of whatever page is loaded — pywebview | |
| # 4.4.1 has no native toolbar API, so this is the only way to add persistent | |
| # chrome without switching GUI frameworks. | |
| # | |
| # The page itself is pushed down/right by exactly the toolbar height and | |
| # sidebar width (via forced <body> padding, not just floating the chrome on | |
| # top of it) so real page content is never hidden underneath — the chrome | |
| # and the website occupy clearly separate regions instead of overlapping. | |
| # overflow-x is force-hidden as a safety net against the small width | |
| # reduction ever introducing a horizontal scrollbar. The one edge case this | |
| # can't fully solve: a small minority of sites use position:fixed elements | |
| # of their own pinned to the true viewport edges (independent of body | |
| # padding) — those can still end up visually behind our chrome. | |
| # ============================================================================= | |
| 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) | |
| # JS: tracking overlay (gaze dot + AOI highlighting) — injected only once | |
| # tracking actually starts. Ported directly from run_session.py's JS_SETUP. | |
| 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; | |
| })(); | |
| """ | |
| # JS: mouse tracking overlay — ported feature-for-feature from the | |
| # "Mouse Tracker & Heatmap" Chrome extension (Mouse/content.js + background.js | |
| # + popup.js), adapted to run as a single injected script instead of a | |
| # content-script/background/popup trio (pywebview has no extension host). | |
| # Same trail/heatmap/dwell sampling loop, same click interest labeling, same | |
| # heatmap render, plus an in-page panel that replaces the extension's popup | |
| # (Start/Stop/Clear/Toggle Heatmap/View Logs, timer, "Most Viewed Element"). | |
| # ============================================================================= | |
| 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) | |
| # ============================================================================= | |
| # API exposed to JS — start/stop/quit are the entry points | |
| # ============================================================================= | |
| # Pages tracking must never start on, checked against window.get_current_url() | |
| # as a Python-side backstop to CHROME_JS's isTrackable check (belt and | |
| # suspenders — the JS guard can't run before the JS bridge is up). | |
| _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 # the folder gaze_worker() is actively writing into right now, else None — the one session delete_*_session() must refuse to touch | |
| 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 # None until login()/create_profile() succeeds | |
| self.active_tracking_subject = None # None = tracking "Myself"; else a participant dict | |
| 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 | |
| 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 | |
| # -- profiles ------------------------------------------------------- | |
| # InsightUX has no server/database — auth.py's users.json *is* the | |
| # backend here. The frontend never supplies a user id as proof of | |
| # anything: list_profiles() only ever returns public fields (never a | |
| # password hash), and login()/create_profile() are the only two ways | |
| # self.current_user gets set, both gated on auth.verify_login()/ | |
| # auth.create_user() actually succeeding server-side (i.e. in this | |
| # Python process). Every other per-user read/write in the app goes | |
| # through SESSIONS_ROOT/CALIBRATION_PATH/_user_onnx_path(), which only | |
| # _apply_user_paths() (called from here) is allowed to repoint. | |
| 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}") | |
| 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 | |
| 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 | |
| # -- participants ---------------------------------------------------- | |
| # A participant is a tracking SUBJECT, not an InsightUX login — they | |
| # never authenticate. Isolation instead comes from where their data | |
| # physically lives: participants.participant_dir() only ever resolves | |
| # to users/<CURRENT_USER_DIR>/participants/<id>/, so a participant of | |
| # one owner can never be reached while a different owner is signed in | |
| # (there is no cross-owner participant_id lookup — the folder simply | |
| # doesn't exist under the wrong owner). | |
| def list_participants(self): | |
| if not CURRENT_USER_DIR: | |
| return [] | |
| return participants.list_participants(CURRENT_USER_DIR) | |
| 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} | |
| 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)} | |
| # Keep the in-memory "who's actively selected" copy (name shown in | |
| # the sidebar's Participant panel / status line) in sync — without | |
| # this a rename wouldn't show up there until a Myself<->them | |
| # round trip re-read it from disk. | |
| 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} | |
| 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: | |
| # Deleted while merely selected (not mid-session) -- fall back | |
| # to "Myself" rather than leaving SESSIONS_ROOT/CALIBRATION_PATH | |
| # pointed at a folder that no longer has a participant record | |
| # behind it. | |
| self.active_tracking_subject = None | |
| _apply_subject_paths(None) | |
| self._set_subject_ui() | |
| return {"ok": True} | |
| 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 # None serializes to JS null -- "Myself" | |
| self.window.evaluate_js( | |
| f"window.insightuxSetSubject && window.insightuxSetSubject({json.dumps(payload)})" | |
| ) | |
| except Exception: | |
| pass | |
| # -- session history: Profile -> My Sessions / (Three-dot) Persons --- | |
| # Both always resolve an EXPLICIT folder, never the currently-active | |
| # SESSIONS_ROOT — so "My Sessions" keeps showing the owner's own | |
| # sessions and "Persons -> Rahul" keeps showing Rahul's, no matter who | |
| # the Participant panel currently has selected. Each call does a fresh | |
| # directory listing (_sessions_json()), so simply opening either panel | |
| # is the "live refresh" — no separate polling/push needed. | |
| def list_my_sessions(self, limit=25): | |
| if not CURRENT_USER_DIR: | |
| return [] | |
| return _sessions_json(os.path.join(CURRENT_USER_DIR, "sessions"), limit) | |
| 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 | |
| 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) | |
| 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: | |
| # webview.FileDialog.SAVE doesn't exist in the pinned pywebview | |
| # 4.4.1 (added in a later release) -- SAVE_DIALOG is the name | |
| # that actually exists here, deprecated or not. | |
| 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 # user cancelled the dialog | |
| # window.create_file_dialog()'s own type hint promises Sequence[str] | |
| # -- but the pinned pywebview 4.4.1's WinForms backend returns a | |
| # plain str for SAVE_DIALOG specifically (dialog.FileName directly, | |
| # unlike OPEN_DIALOG/FOLDER_DIALOG which do wrap their result in a | |
| # tuple). Trusting the type hint here silently wrote to a mangled | |
| # one-character path (result[0] on a string is just its first | |
| # character) instead of anywhere the user actually chose. | |
| 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 | |
| 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) | |
| 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 | |
| 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) | |
| 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 | |
| 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) | |
| 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) | |
| 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) | |
| 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}") | |
| 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() | |
| # Give the page a moment to flush its last batch of mouse data over | |
| # log_mouse_data() before the file handle is closed. | |
| 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 | |
| def quit_app(self): | |
| # X is an emergency kill switch, not a graceful shutdown — the user | |
| # wants the terminal process gone immediately, camera/threads and all. | |
| _log("[browser_session] quit_app() called from JS -- terminating process") | |
| os._exit(0) | |
| def go_home(self): | |
| # A page loaded over http(s) can't script-navigate itself to a | |
| # file:// URL — Chromium/WebView2 silently blocks it (no exception, | |
| # no navigation), which is what made the toolbar's Home button a | |
| # no-op via location.href. window.load_url() is a native host call, | |
| # not page script, so it isn't subject to that restriction — same | |
| # mechanism the report page's own navigation already relies on. | |
| _log("[browser_session] go_home() called from JS") | |
| try: | |
| # _write_landing_page() writes to the same fixed path every | |
| # time and returns the same URL — but LANDING_URL itself was | |
| # only ever computed once, at app startup, so the "Recent | |
| # Sessions" list it baked in went stale after the first session | |
| # of the run. Rebuild it fresh right before navigating so Home | |
| # always reflects whatever sessions exist right now. | |
| 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 | |
| # -- calibration / validation ------------------------------------------ | |
| # calibrate.py and validate.py are unmodified, standalone OpenCV/pyautogui | |
| # scripts (their own fullscreen window, their own event loop) — they were | |
| # never meant to share a process with pywebview's GUI loop. Launching them | |
| # as a subprocess reuses them exactly as-is instead of rewriting their | |
| # display logic into the browser. | |
| 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 | |
| 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 | |
| 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" | |
| # Pass the session's real start time (fixed at start_tracking(), | |
| # unchanged by later re-injections) instead of letting the JS | |
| # stamp its own "now" — this same call fires again on every | |
| # page navigation while tracking is active (on_page_loaded() | |
| # re-injects the whole toolbar), and a fresh JS context has no | |
| # memory of when the session actually began. | |
| 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) | |
| # Same reasoning as the tracking re-sync above: CHROME_JS was just | |
| # re-injected fresh and has no memory of who's signed in, or who the | |
| # active tracking subject was. | |
| if api.current_user: | |
| api._set_profile_ui() | |
| api._set_subject_ui() | |
| # CHROME_JS was just re-injected fresh on this page and has no memory of | |
| # an update found on a previous page — re-tell it if one was found. | |
| if api.update_info: | |
| api._notify_update(api.update_info["version"], api.update_info["url"]) | |
| # ============================================================================= | |
| # GAZE WORKER — runs in a background thread, started/stopped via Api | |
| # ============================================================================= | |
| 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(): | |
| # Checked here, before GazeLogger/dom_f/screenshots create anything | |
| # on disk, rather than after the fact -- start_tracking() can't do | |
| # this check itself since opening the camera is a blocking call and | |
| # has to happen in this background thread, not the UI thread. Before | |
| # this, a busy/absent/permission-denied camera meant cap.read() just | |
| # returned (False, None) on the very first iteration below, the loop | |
| # silently `break`-ed, and the session fell straight through to | |
| # report generation on zero frames -- a confusing empty report with | |
| # no indication anything had gone wrong. | |
| 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 # px scrolled before a new snapshot is worth taking | |
| MAX_SHOT_INTERVAL = 2.5 # seconds — take one anyway if you just sit still | |
| 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) # let the hide actually repaint before the OS-level grab | |
| try: | |
| img = pyautogui.screenshot() | |
| finally: | |
| try: | |
| window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(false)") | |
| except Exception: | |
| pass | |
| # Force to the same pixel space as gaze coordinates (SCREEN_W x | |
| # SCREEN_H) — Windows DPI scaling can otherwise return a | |
| # screenshot at a different resolution than pyautogui.size(), | |
| # which would silently misalign every heatmap point. | |
| 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) | |
| # Eye aperture — the vertical cue. Calibration decides whether this or | |
| # the CNN's pitch actually tracks screen-Y, and uses the better one. | |
| 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) | |
| # Smooth the ANGLES before the RBF sees them. The RBF amplifies input | |
| # noise (steep gradient along the low-variance pitch axis), so noise | |
| # must be removed here — smoothing the screen coords afterwards cannot | |
| # undo amplification that already happened through a nonlinear map. | |
| 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}") | |
| # generate report and hand control back | |
| 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 | |
| # ============================================================================= | |
| # MAIN | |
| # ============================================================================= | |
| def _go_fullscreen(window): | |
| # Small delay so WebView2 fully initializes and grabs keyboard focus | |
| # BEFORE going fullscreen. Passing fullscreen=True straight to | |
| # create_window is what causes the "can't type" issue and the | |
| # AccessibilityObject.Bounds recursion crash on Windows — both trace | |
| # back to the WinForms host not finishing its focus/accessibility | |
| # setup before the fullscreen transition happens. | |
| time.sleep(0.4) | |
| try: | |
| window.toggle_fullscreen() | |
| except Exception as e: | |
| _log(f"[browser_session] fullscreen toggle failed: {e}") | |
| if __name__ == "__main__": | |
| # A packaged build is one exe with no separate calibrate.py/validate.py | |
| # files to shell out to — --mode makes it a single self-dispatching | |
| # entry point instead. See _relaunch_args() above for the launch side. | |
| 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: | |
| # Opens on the login/profile picker, not the landing page — no | |
| # login state is persisted across restarts, so a password is | |
| # always required before any profile's sessions/calibration/ | |
| # reports become reachable. A fresh listing of profiles is | |
| # written right before opening in case a previous run created one. | |
| 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() | |
| # debug=True enables right-click > Inspect (DevTools) so you can see | |
| # the [insightux] console.log diagnostics from CHROME_JS directly — | |
| # keyboard handling runs entirely in the browser, so JS-side issues | |
| # never show up in this terminal, only in DevTools' Console tab. | |
| webview.start(debug=True) |