diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c2abde1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,76 @@ +# AI contributor guide for this repo + +This project is a Python OSINT tool that maps a Steam user’s friend network, enriches it with graph metrics, and exports Gephi‑ready CSVs plus lightweight "probable friends" and IRL/location estimates. + +## Big picture +- Entrypoints + - `app.py` — primary CLI with modes (full/basic/graphi) and optional SteamHistory support. Saves `scan.json`, exports CSVs, writes `estimates.json`, and logs to `run.log`. + - `start.py` — simple wizard subset (legacy), focused on scan + Gephi CSVs + probable friends. + - `gui.py` — optional PyWebView UI that calls Python via a `Bridge` API; static HTML/JS in `ui/` (currently demo‑level wiring). +- Core modules (in `vapora/`) + - `steam_api.py` — HTTP wrapper + RPM rate limiter; vanity resolver; batched summaries/bans; optional group list. + - `scanner.py` — breadth‑first crawl up to `depth`, with `max_nodes` cap, producing a serializable `state` dict. + - `enricher.py` — cleans edges, builds `networkx` graph, computes degree/betweenness/modularity, exports CSVs under `graphi/`. + - `probable_friends.py` — ranks close associates using mutuals/Jaccard/shared groups; writes `probable_friends.csv`. + - `irl.py` — IRL friend probability and possible location inference from the scanned network; outputs are embedded in `estimates.json`. + - `steamhistory.py` — helpers to ingest normalized SteamHistory JSON and derive duration‑based closeness (fallback when network signals are absent). + - `utils.py` — small helpers (timestamps, open folder, simple file logger). + +State contract (produced by `scanner.scan_network` and consumed elsewhere): +- `state = { seed: str, nodes: {steamid: {...}}, edges: [{a,b,type}], visited: [..], queue: [..], meta: {depth} }` +- `nodes[steamid]` contains at least: `friends: [steamid]`, `personaname`, `profileurl`, `is_public`, optional `bans`, `groups`, and location codes. +- `edges[i]` has `a`, `b`, and `type` in `{friend|group}`. `enricher._clean_edges` removes duplicates/dangling edges. + +Important repo conventions +- Config source of truth: `vapora/config_default.yaml`. CLI can persist user profiles to `profiles/*.yaml`. +- Output layout: `outputs///` containing `scan.json`, `run.log`, optional `estimates.json`, and a `graphi/` folder with `nodes.csv` and `edges.csv`. + - Note: folder name is `graphi` in code (typo vs README’s "gephi"). Keep code/doc consistent or fix both together. +- Modes gate features (see `app._mode_caps`): + - `full`: graphi + estimates + SteamHistory fallback + - `basic`: estimates + SteamHistory only (no CSVs) + - `graphi`: CSV export only (no estimates/SH) +- Identifiers: Accepts SteamID2/3/64, profile URLs, or vanity; resolve via `ids.parse_any_steam_input` + `SteamAPI.ensure_steam64_from_vanity`. +- Rate limiting: simple in‑process windowed RPM limiter in `steam_api.RateLimiter.wait()` used by every call. + +## Developer workflows +- Prereqs: Python 3.10+, a Steam Web API key. +- Setup (Windows PowerShell) + - Create venv and install deps, then run the CLI: + - `python -m venv .venv; .venv\Scripts\Activate.ps1; pip install -r requirements.txt; python app.py` + - On first run, paste `STEAM_API_KEY` when prompted (stored in `.env` next to the script). + - Quick wizard (legacy): `python start.py`. +- Optional GUI: `gui.py` uses PyWebView; install `pywebview` to use it, then `python gui.py`. The current `ui/app.js` is a demo; the Python `Bridge` is the source of truth for actions. +- Debugging tips + - Use recent outputs via menu options in CLI, or open the newest run dir under `outputs/`. + - Inspect `run.log` for step‑by‑step notes (scan, exports, estimates). + - If APIs look slow/noisy, lower `depth`/`max_nodes`, or raise `rate_limit_rpm` (default 120). + +## Extending the tool (patterns) +- New analysis that uses the scanned network: + - Add a pure function `def analyze(state: Dict, out_dir: Path, **opts) -> Path|Dict` under `vapora/`. + - Consume the `state` contract above; don’t make new network calls unless necessary. + - Write results next to existing outputs (e.g., `out_dir / "your_feature.json"`). + - Wire it in `app.run_scan` and/or `gui.Bridge.run_scan` behind a mode/config flag. +- Exporters: follow `enricher.export_gephi(state, out_dir, hub_percentile)`; keep CSV schemas stable. +- Steam API calls: prefer adding thin wrappers in `SteamAPI` and reuse the existing batching + limiter. + +## External dependencies and IO +- HTTP: `requests` to official Web API endpoints only; no scraping. +- Graph/communities: `networkx`, `python-louvain`. +- CLI UX: `questionary`, `rich` (Windows‑safe colors configured), `tqdm` for progress. +- Optional GUI: `pywebview` (not listed in requirements.txt by default). + +## Gotchas to know +- Output folder is `graphi/` in code. If the README says `gephi/`, that’s a known mismatch. +- Group links can explode edge count on large public groups; consider toggling `include_group_links` off. +- Private profiles are silently kept with `is_public=False`; group membership lookups are skipped when `skip_private_profiles=true`. +- `requirements.txt` omits `pywebview` (GUI) and `colorama` (used in `start.py`); install them explicitly if needed. + +## Handy references +- Examples: + - BFS scan: `vapora/scanner.py: scan_network(...)` + - CSV export: `vapora/enricher.py: export_gephi(...)` + - Probable friends: `vapora/probable_friends.py: compute_probable_friends(...)` + - IRL estimates + locations: `vapora/irl.py` + - SteamHistory helpers: `vapora/steamhistory.py` +- Config defaults and flags: `vapora/config_default.yaml` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e69de29 diff --git a/README copy.md b/README copy.md new file mode 100644 index 0000000..ebb48c4 --- /dev/null +++ b/README copy.md @@ -0,0 +1,278 @@ +

+ + vapora Logo + +

+ +

an OSINT tool for gathering information on Steam users' friends lists.

+ +

+ License + Stars + Issues +

+ +--- + +## tl;dr +interactive python tool to map a steam user’s friend network, enrich it with community metrics, and export gephi‑ready csvs & a “probable friends” report: + +- install python 3.10+ +- get a steam api key (free): https://steamcommunity.com/dev/apikey +- `pip install -r requirements.txt` +- `python start.py` → paste key → choose preset → paste steam url → done +- open the folder in gephi (see how‑to below) + +--- + +## features + +- blue terminal wizard (reads ascii from `assets/banner.txt`) +- works with steamid64 or profile url (auto vanity resolver) +- safe presets: + - inner circle (fast) → depth 1, ~200–300 nodes + - community map (default) → depth 2, ~500 nodes + - custom → full control with explanations +- dry‑run estimator (samples seed friends to predict node counts) +- resume last run + recent targets menu +- rate‑limit handling, retries, progress bars +- automatic cleaning (no phantom nodes in gephi) +- gephi‑ready exports (no html): + - `gephi/nodes.csv` | label + metrics + - `gephi/edges.csv` | `friend` vs `group` edges + - `probable_friends.csv` | ranked close‑associate guesses +- enrichment for osint: + - degree (popularity) + - betweenness centrality (bridges / hubs) + - modularity class (communities; louvain) + - “is_hub” flag (top percentile of betweenness) +- cross‑platform; outputs per target with timestamp +- optional packaged exe (pyinstaller) for windows + +--- + +## how it works + +1. the wizard collects: + - steam api key (stores to `.env`; can skip if already set) + - target (steamid64 or profile url) + - preset / custom config (depth, caps, rate limit, etc.) +2. scanner hits the steam web api (depth‑limited bfs). +3. cleaner removes dangling edges; keeps `Kind = friend|group`. +4. enricher computes degree, betweenness, modularity, is_hub. +5. probable‑friends analyzer ranks likely close associates. +6. exports gephi csvs + raw scan json into a dated folder. + +no scraping; only public web‑api endpoints. private data is skipped. + +--- + +## layout + +``` +. +├─ start.py # cli wizard +├─ vapora-X.X.X.exe # windows executable +├─ .env.example # steam api key placeholder +├─ requirements.txt +├─ assets/ +│ └─ banner.txt +├─ vapora/ +│ ├─ steam_api.py # api wrapper + rate limiting + vanity resolver +│ ├─ scanner.py # bfs crawler (depth, caps, resume) +│ ├─ enricher.py # clean + metrics + gephi csv export +│ ├─ probable_friends.py # close-associate ranking +│ ├─ utils.py # helpers (paths, time, io) +│ └─ config_default.yaml # defaults with inline docs +├─ profiles/ # saved config profiles +├─ outputs/ # results +├─ .gitignore +├─ LICENSE +└─ README.md +``` + +outputs per run: +``` +outputs/// +├─ gephi/ +│ ├─ nodes.csv +│ └─ edges.csv +├─ probable_friends.csv +├─ scan.json +└─ run.log +``` + +--- + +## installation + +prereqs +- python 3.10+ +- steam api key: https://steamcommunity.com/dev/apikey + +clone + install +```bash +git clone https://github.com/Microck/vapora.git +cd vapora + +python -m venv .venv +# windows +.venv\Scripts\activate +# macos/linux +source .venv/bin/activate + +pip install -r requirements.txt +``` + +set your key +- copy `.env.example` → `.env` and paste your key, or +- just run the wizard; it can create `.env` for you + +--- + +## quickstart + +```bash +python start.py +``` + +you’ll see: +- a short tip + link to get your api key +- menu: + - scan by steamid64 + - scan by profile url (vanity / full) + - presets (inner circle / community map / custom) + - config (guided editor with recommended defaults) + - dry‑run estimate + - resume last run + - recent targets + - run + +after the run it asks to open the output folder. the readme below explains +how to import into gephi. + +--- + +## configuration + +the wizard shows a one‑liner help for each option and saves your choices to +`profiles/` so you can reuse them later. + +defaults (also in `vapora/config_default.yaml`): +```yaml +depth: 2 # 1 = only friends; 2 = friends of friends, 3 = you get how it goes +max_nodes: 500 # hard cap; keeps graphs tidy +rate_limit_rpm: 120 # requests per minute +skip_private_profiles: true +include_group_links: true # group edges (toggle off in gephi if noisy) +include_game_overlap: false +hub_percentile: 0.99 # top 1% betweenness → is_hub=true +weights: # probable-friends scoring + mutual: 1.0 + jaccard: 1.0 + groups: 0.5 + games: 0.5 +``` + +advanced: +- hub threshold is adjustable (percentile) from the wizard “advanced” section. +- profiles can be saved/loaded with a name. + +--- + +## output files schemas + +`gephi/nodes.csv` +- Id +- Label +- degree +- betweenness +- modularity_class +- is_seed (true/false) +- is_hub (true/false) +- is_banned (true/false) +- is_public (true/false) + +`gephi/edges.csv` +- Source +- Target +- Kind (`friend` | `group`) + +`probable_friends.csv` (ranked) +- candidate_steamid +- score +- mutual_count +- jaccard_with_seed +- shared_groups +- shared_games + +--- + +## gephi how‑to (step‑by‑step) + +1) open gephi → new project +2) import `gephi/nodes.csv` as “nodes table” +3) import `gephi/edges.csv` as “edges table” (undirected, append) +4) layout → forceatlas 2 + - scaling 25 + - linlog ✓ + - prevent overlap ✓ + - run 20–30s → stop, then “noverlap” a few seconds +5) appearance + - nodes → partition → `modularity_class` → apply (color communities) + - nodes → ranking → `betweenness` → apply (size hubs: 8–50) +6) optional filter + - filters → edges → attributes → partition → `Kind` → select `friend` + - toggle `friend` vs `group` to see core friendships vs shared context + +--- + +## probable friends + +aim: guess close associates of the seed using public signals: +- mutual friend count with seed (triadic closure) +- neighbor‑set jaccard with seed +- shared groups bonus +- shared games bonus (if public) + +weights are tunable in config. the analyzer skips private data silently. + +--- + +## tips for osint in gephi + +- start with `Kind = friend` (skeleton); toggle `group` later for context +- “degree range” filter (min 2–3) removes tails and reveals the core +- “k‑core” (k=3→6) finds inner circles (mutually connected cliques) +- gatekeepers: large betweenness nodes between different colors +- leaders: isolate one color (partition filter) then size by degree + +--- + +## troubleshooting + +- forbidden / “verify key” → check `.env` contains a valid `STEAM_API_KEY` +- gephi stuck at 0% → lower `max_nodes` (default 500), use `Kind=friend` +- “ghost nodes” → we export clean edges; import nodes first, then edges +- slow scans → reduce `depth`, ensure `rate_limit_rpm` ≥ 60 + +--- + + +## faq + +- does this scrape? + no, it uses the official web api; private data is skipped. + +- is this allowed? + use a legitimate api key and respect rate limits; do not bypass restrictions. + +--- + +## license + + +mit © microck — see [license](LICENSE) + + + diff --git a/app.py b/app.py new file mode 100644 index 0000000..12ed14f --- /dev/null +++ b/app.py @@ -0,0 +1,528 @@ +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Dict, Optional, Tuple + +import questionary as q +import yaml +from dotenv import load_dotenv +from questionary import Style +from rich.console import Console +from rich.theme import Theme + +from vapora.enricher import export_gephi +from vapora.ids import parse_any_steam_input +from vapora.irl import ( + FriendProbOpts, + LocationProbOpts, + build_estimates, +) +from vapora.scanner import scan_network +from vapora.steamhistory import ( + fetch_profile_json, + analyze_closeness_by_duration, + extract_main_info, +) +from vapora.steam_api import SteamAPI +from vapora.utils import open_folder, stamp, RunLogger + + +# ────────────────────────────── Initialization + +THEME = Theme({"accent": "cyan", "hint": "cyan", "warn": "yellow"}) +console = Console(theme=THEME) + + +def resource_path(*parts: str) -> Path: + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + base = Path(sys._MEIPASS) # type: ignore[attr-defined] + else: + base = Path(__file__).parent + return base.joinpath(*parts) + + +def app_root() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).parent + + +ROOT = app_root() +ASSETS = resource_path("assets") +OUTPUTS = ROOT / "outputs" +PROFILES = ROOT / "profiles" +DEFAULT_CFG = resource_path("vapora", "config_default.yaml") +ENV = ROOT / ".env" + +# ────────────────────────────── Styles (CMD-Safe) +CUSTOM_STYLE = Style( + [ + ("qmark", "fg:yellow bold"), + ("question", "fg:cyan bold"), + ("answer", "fg:green bold"), + ("pointer", "fg:yellow bold"), + ("selected", "fg:black bg:yellow bold"), + ("highlighted", "fg:black bg:yellow bold"), + ("instruction", "fg:gray"), + ("text", ""), + ("disabled", "fg:gray"), + ] +) + + +# ────────────────────────────── Banner + +def clear_cmd() -> None: + os.system("cls" if os.name == "nt" else "clear") + + +def print_banner(cfg: Optional[Dict] = None) -> None: + clear_cmd() + banner_file = ASSETS / "banner.txt" + if banner_file.exists(): + banner = banner_file.read_text(encoding="utf-8", errors="ignore") + print(banner) + else: + print("steam-friends-osint") + mode = (cfg or {}).get("run_mode", "full").upper() + print(f"Mode: {mode}") + print("-" * 70 + "\n") + + +# ────────────────────────────── ENV / Config + +def _ensure_env() -> None: + load_dotenv(dotenv_path=ENV) + key = os.getenv("STEAM_API_KEY", "").strip() + if key: + return + console.print( + "Get your API key: https://steamcommunity.com/dev/apikey", style="hint" + ) + key = q.text("Paste your STEAM_API_KEY", style=CUSTOM_STYLE).ask() + if not key: + console.print("No key; exiting.", style="warn") + sys.exit(1) + ENV.write_text(f"STEAM_API_KEY={key}\n", encoding="utf-8") + load_dotenv(dotenv_path=ENV, override=True) + + +def _load_default_cfg() -> Dict: + data = yaml.safe_load(DEFAULT_CFG.read_text(encoding="utf-8")) + PROFILES.mkdir(parents=True, exist_ok=True) + return data + + +# ────────────────────────────── Mode helpers + +def _mode_caps(cfg: Dict) -> Dict[str, bool]: + """Feature gates for modes.""" + mode = cfg.get("run_mode", "full").lower() + if mode == "graphi": + return { + "graphi": True, + "estimates": False, + "steamhistory": False, + } + if mode == "basic": + return { + "graphi": False, + "estimates": True, + "steamhistory": True, + } + # full + return { + "graphi": True, + "estimates": True, + "steamhistory": True, + } + + +def _pick_mode(cfg: Dict) -> Dict: + mode = q.select( + "Select run mode:", + choices=["Full", "Basic", "Graphi", "Back"], + style=CUSTOM_STYLE, + ).ask() + if not mode or mode == "Back": + return cfg + cfg["run_mode"] = mode.lower() + console.print(f"Mode set to {mode}", style="accent") + return cfg + + +# ────────────────────────────── Prompts + +def _ask_target() -> Optional[str]: + s = q.text( + "Target (SteamID / SteamID3 / SteamID64 / vanity / URL):", + style=CUSTOM_STYLE, + ).ask() + return s.strip() if s else None + + +def _pick_preset(cfg: Dict) -> Dict: + choice = q.select( + "Choose a preset:", + choices=[ + "Inner circle (depth 1, small)", + "Community map (depth 2, ~500 nodes)", + "Custom (load/save profile)", + "Back", + ], + style=CUSTOM_STYLE, + ).ask() + if choice == "Inner circle (depth 1, small)": + cfg.update({"depth": 1, "max_nodes": 300}) + elif choice == "Community map (depth 2, ~500 nodes)": + cfg.update({"depth": 2, "max_nodes": 500}) + elif choice == "Custom (load/save profile)": + cfg = _profiles_menu(cfg) + return cfg + + +def _profiles_menu(cfg: Dict) -> Dict: + PROFILES.mkdir(parents=True, exist_ok=True) + profiles = [p.stem for p in PROFILES.glob("*.yaml")] + choice = q.select( + "Profiles:", + choices=["Save current as...", *profiles, "Back"], + style=CUSTOM_STYLE, + ).ask() + if choice == "Save current as...": + name = q.text("Profile name:", style=CUSTOM_STYLE).ask() + if name: + path = PROFILES / f"{name}.yaml" + path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8") + console.print(f"Saved profiles/{name}.yaml", style="accent") + elif choice and choice != "Back": + path = PROFILES / f"{choice}.yaml" + cfg = yaml.safe_load(path.read_text(encoding="utf-8")) + console.print(f"Loaded profiles/{choice}.yaml", style="accent") + return cfg + + +def _guided_config(cfg: Dict) -> Dict: + caps = _mode_caps(cfg) + console.print("Guided Config (Press Enter for default)", style="accent") + + # Common scan parameters + cfg["depth"] = int( + q.text(f"Depth (1–3) [default {cfg['depth']}]", style=CUSTOM_STYLE).ask() + or cfg["depth"] + ) + cfg["max_nodes"] = int( + q.text(f"max_nodes [default {cfg['max_nodes']}]", style=CUSTOM_STYLE).ask() + or cfg["max_nodes"] + ) + cfg["rate_limit_rpm"] = int( + q.text( + f"rate_limit_rpm [default {cfg['rate_limit_rpm']}]", + style=CUSTOM_STYLE, + ).ask() + or cfg["rate_limit_rpm"] + ) + cfg["skip_private_profiles"] = q.confirm( + f"skip_private_profiles? [default {cfg['skip_private_profiles']}]", + default=cfg["skip_private_profiles"], + style=CUSTOM_STYLE, + ).ask() + cfg["include_group_links"] = q.confirm( + f"include_group_links? [default {cfg['include_group_links']}]", + default=cfg["include_group_links"], + style=CUSTOM_STYLE, + ).ask() + + if caps["graphi"]: + if q.confirm( + "Advanced (hub threshold etc.)?", + default=False, + style=CUSTOM_STYLE, + ).ask(): + cfg["hub_percentile"] = float( + q.text( + f"hub_percentile (0.95–0.999) [default {cfg['hub_percentile']}]", + style=CUSTOM_STYLE, + ).ask() + or cfg["hub_percentile"] + ) + + if caps["estimates"]: + irl = cfg.get("irl", {}) + irl["top_n_for_mean"] = int( + q.text( + f"IRL top_n_for_mean [default {irl.get('top_n_for_mean', 5)}]", + style=CUSTOM_STYLE, + ).ask() + or irl.get("top_n_for_mean", 5) + ) + irl["reasonable_count"] = int( + q.text( + f"IRL reasonable_count [default {irl.get('reasonable_count', 50)}]", + style=CUSTOM_STYLE, + ).ask() + or irl.get("reasonable_count", 50) + ) + cfg["irl"] = irl + + loc = cfg.get("location", {}) + loc["reasonable_count"] = int( + q.text( + f"Location reasonable_count [default {loc.get('reasonable_count', 100)}]", + style=CUSTOM_STYLE, + ).ask() + or loc.get("reasonable_count", 100) + ) + loc["score_strategy"] = ( + q.select( + "Location score_strategy", + choices=["multiply", "sum"], + style=CUSTOM_STYLE, + ).ask() + or loc.get("score_strategy", "multiply") + ) + cfg["location"] = loc + + if caps["steamhistory"]: + cfg["request_timeout"] = int( + q.text( + f"steamhistory request_timeout [default {cfg.get('request_timeout', 25)}]", + style=CUSTOM_STYLE, + ).ask() + or cfg.get("request_timeout", 25) + ) + + return cfg + + +# ────────────────────────────── Core logic + +def _make_api(cfg: Dict) -> SteamAPI: + key = os.getenv("STEAM_API_KEY", "").strip() + return SteamAPI(key, rpm=cfg["rate_limit_rpm"]) + + +def _resolve_any_target(api: SteamAPI, s: str) -> Optional[str]: + sid64 = parse_any_steam_input(s, api) + if not sid64: + console.print("Could not resolve target.", style="warn") + return sid64 + + +def dry_run(target: str, cfg: Dict) -> None: + api = _make_api(cfg) + sid = _resolve_any_target(api, target) + if not sid: + return + + friends = api.get_friend_list(sid) + est_depth1 = len(friends) + console.print(f"Seed friends ~ {est_depth1}", style="accent") + + +def _maybe_load_steamhistory( + cfg: Dict, logger: RunLogger +) -> Optional[Tuple[Dict, Dict]]: + """ + Ask user to paste a SteamHistory JSON/NDJSON URL or file path (optional). + Returns (main_info, closeness_by_duration) or None if skipped. + """ + s = q.text( + "Optional: SteamHistory JSON/NDJSON URL or file path (Enter to skip)", + style=CUSTOM_STYLE, + ).ask() + if not s: + return None + try: + data = fetch_profile_json(s, timeout=cfg.get("request_timeout", 25)) + main_info = extract_main_info(data) + closeness = {"friends": analyze_closeness_by_duration(data)} + logger.write("Loaded SteamHistory JSON.") + return main_info, closeness + except Exception as e: + logger.write(f"SteamHistory fetch failed: {e}") + return None + + +def run_scan(target: str, cfg: Dict) -> None: + caps = _mode_caps(cfg) + api = _make_api(cfg) + sid = _resolve_any_target(api, target) + if not sid: + return + + out_dir = OUTPUTS / sid / stamp() + out_dir.mkdir(parents=True, exist_ok=True) + runlog = RunLogger(out_dir / "run.log") + runlog.write(f"Target resolved to SteamID64: {sid}") + console.print(f"Output → {out_dir}", style="accent") + + # Optional SteamHistory analysis (Full/Basic only) + sh = None + if caps["steamhistory"]: + sh = _maybe_load_steamhistory(cfg, runlog) + + # Scan network + runlog.write("Scanning network...") + state = scan_network( + api=api, + seed_steamid=sid, + depth=cfg["depth"], + rpm=cfg["rate_limit_rpm"], + max_nodes=cfg["max_nodes"], + skip_private=cfg["skip_private_profiles"], + include_group_links=cfg["include_group_links"], + resume_state=None, + ) + runlog.write( + f"Scan complete. Nodes={len(state['nodes'])}, " + f"Edges={len(state['edges'])}." + ) + + # Save scan.json (always) + scan_path = out_dir / "scan.json" + scan_path.write_text(json.dumps(state, indent=2), encoding="utf-8") + runlog.write(f"Wrote scan.json") + + # Graph output (nodes/edges under 'graphi/') + if caps["graphi"]: + nodes_csv, edges_csv = export_gephi( + state=state, out_dir=out_dir, hub_percentile=cfg["hub_percentile"] + ) + runlog.write(f"Wrote graphi: {nodes_csv.name}, {edges_csv.name}") + + # Estimates (Closest friends + Possible locations) in one file + estimates = None + if caps["estimates"]: + friend_opts = FriendProbOpts( + top_n_for_mean=cfg.get("irl", {}).get("top_n_for_mean", 5), + reasonable_count=cfg.get("irl", {}).get("reasonable_count", 50), + ) + loc_opts = LocationProbOpts( + reasonable_count=cfg.get("location", {}).get("reasonable_count", 100), + score_strategy=cfg.get("location", {}).get("score_strategy", "multiply"), + ) + estimates = build_estimates(state, friend_opts, loc_opts) + # If we have SH duration results and network gave none, fallback + if not estimates["closestFriends"] and sh: + estimates["closestFriends"] = sh[1]["friends"][:20] + runlog.write("No network-based closest friends; " + "used SteamHistory duration fallback.") + est_path = out_dir / "estimates.json" + est_path.write_text(json.dumps(estimates, indent=2), encoding="utf-8") + runlog.write(f"Wrote estimates.json") + + # Show results in CLI + if estimates: + cf = estimates.get("closestFriends", [])[:10] + pl = estimates.get("possibleLocations", [])[:5] + if cf: + console.print("Top closest friends:", style="accent") + for r in cf: + label = r.get("personaname") or r.get("name") or r["steamid"] + console.print(f"- {label}: {round(r['probability'],2)}%") + if pl: + console.print("Top possible locations:", style="accent") + for r in pl: + loc = r["location"] + cc = loc.get("countryCode") or "?" + st = loc.get("stateCode") or "" + cid = loc.get("cityID") + console.print( + f"- {cc}-{st}-{cid}: {round(r['probability'],2)}%" + ) + + runlog.write("Done.") + runlog.close() + if q.confirm("Open output folder?", default=True, style=CUSTOM_STYLE).ask(): + open_folder(out_dir) + + +def resume_last(cfg: Dict) -> None: + seeds = sorted( + OUTPUTS.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True + ) + if not seeds: + console.print("No previous outputs found", style="warn") + return + seed_dir = seeds[0] + runs = sorted( + seed_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True + ) + if not runs: + console.print("No runs found", style="warn") + return + open_folder(runs[0]) + + +def pick_recent(cfg: Dict) -> None: + targets = sorted([p.name for p in OUTPUTS.glob("*") if p.is_dir()], reverse=True) + if not targets: + console.print("No targets yet", style="warn") + return + sid = q.select("Recent targets", choices=targets + ["Back"], style=CUSTOM_STYLE).ask() + if not sid or sid == "Back": + return + runs = sorted([p.name for p in (OUTPUTS / sid).glob("*") if p.is_dir()], reverse=True) + if not runs: + console.print("No runs for that target", style="warn") + return + run = q.select("Choose run", choices=runs + ["Back"], style=CUSTOM_STYLE).ask() + if run and run != "Back": + open_folder(OUTPUTS / sid / run) + + +# ────────────────────────────── Entry point + +def main() -> None: + cfg = _load_default_cfg() + print_banner(cfg) + _ensure_env() + + while True: + caps = _mode_caps(cfg) + choices = [ + "Scan target", + "Mode", + "Presets", + "Config", + "Dry-run estimate", + "Recent targets", + "Quit", + ] + choice = q.select( + "What do you want to do?", + choices=choices, + style=CUSTOM_STYLE, + ).ask() + + if not choice or choice == "Quit": + break + if choice == "Scan target": + target = _ask_target() + if target: + run_scan(target, cfg) + elif choice == "Mode": + cfg = _pick_mode(cfg) + print_banner(cfg) + elif choice == "Presets": + cfg = _pick_preset(cfg) + elif choice == "Config": + cfg = _guided_config(cfg) + print_banner(cfg) + elif choice == "Dry-run estimate": + target = _ask_target() + if target: + dry_run(target, cfg) + elif choice == "Recent targets": + pick_recent(cfg) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nctrl-c; bye") \ No newline at end of file diff --git a/assets/folder.jpg b/assets/folder.jpg new file mode 100644 index 0000000..b4da510 Binary files /dev/null and b/assets/folder.jpg differ diff --git a/assets/key.png b/assets/key.png new file mode 100644 index 0000000..9e63f9f Binary files /dev/null and b/assets/key.png differ diff --git a/assets/log.png b/assets/log.png new file mode 100644 index 0000000..ca18479 Binary files /dev/null and b/assets/log.png differ diff --git a/assets/placeholder.jpg b/assets/placeholder.jpg new file mode 100644 index 0000000..eff0700 Binary files /dev/null and b/assets/placeholder.jpg differ diff --git a/assets/presets.png b/assets/presets.png new file mode 100644 index 0000000..b6624d3 Binary files /dev/null and b/assets/presets.png differ diff --git a/assets/save.svg b/assets/save.svg new file mode 100644 index 0000000..f765241 --- /dev/null +++ b/assets/save.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..5f00dc6 --- /dev/null +++ b/gui.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Dict, List, Optional + +import webview +import yaml + +from vapora.enricher import export_gephi +from vapora.ids import parse_any_steam_input +from vapora.irl import FriendProbOpts, LocationProbOpts, build_estimates +from vapora.scanner import scan_network +from vapora.steam_api import SteamAPI +from vapora.utils import stamp, RunLogger, open_folder + +ROOT = Path(__file__).parent +OUTPUTS = ROOT / "outputs" +CONFIG_PATH = ROOT / "vapora" / "config_default.yaml" +UI_DIR = ROOT / "ui" +PROFILES = ROOT / "profiles" +ENV_PATH = ROOT / ".env" +ENV_EXAMPLE_PATH = ROOT / ".env.example" + + +PLACEHOLDER_LINE = "STEAM_API_KEY=your_api_key_here" + + +def _load_env_file() -> None: + """Minimal .env loader: sets os.environ for key=value pairs.""" + path = ENV_PATH if ENV_PATH.exists() else (ENV_EXAMPLE_PATH if ENV_EXAMPLE_PATH.exists() else None) + if not path: + return + try: + for line in path.read_text(encoding="utf-8").splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + k = k.strip() + v = v.strip().strip("\"\'") + # Skip placeholder + if k == "STEAM_API_KEY" and (not v or v == "your_api_key_here"): + continue + if k and v and k not in os.environ: + os.environ[k] = v + except Exception: + pass + + +def _mode_caps(mode: str) -> Dict[str, bool]: + m = (mode or "full").lower() + if m == "graphi": + return {"graphi": True, "estimates": False} + if m == "basic": + return {"graphi": False, "estimates": True} + return {"graphi": True, "estimates": True} + + +class Bridge: + def __init__(self, cfg: Dict): + self.cfg = cfg + self._window: Optional[webview.window.Window] = None # type: ignore[attr-defined] + self._pos = (0, 0) # last known window position (x, y) + PROFILES.mkdir(parents=True, exist_ok=True) + # Ensure .env is loaded on startup so os.getenv works everywhere + _load_env_file() + + # Window control hooks for frameless UI + def attach(self, win) -> bool: + self._window = win + return True + + def win_close(self) -> bool: + try: + if self._window: + self._window.destroy() + return True + except Exception: + return False + return False + + def win_minimize(self) -> bool: + try: + if self._window: + self._window.minimize() + return True + except Exception: + return False + return False + + def win_move_by(self, dx: int, dy: int) -> bool: + """Move window by a delta amount (manual drag fallback).""" + try: + if not self._window: + return False + x = int(self._pos[0]) + int(dx) + y = int(self._pos[1]) + int(dy) + # clamp a little to avoid negative overshoot causing OS snap glitches + x = max(-32000, x) + y = max(-32000, y) + self._window.move(x, y) + self._pos = (x, y) + return True + except Exception: + return False + + def win_resize(self, width: int, height: int) -> bool: + """Resize window to an explicit size.""" + try: + if not self._window: + return False + w = max(640, int(width)) + h = max(400, int(height)) + self._window.resize(w, h) + return True + except Exception: + return False + + # ----- Config I/O ----- + def get_config(self) -> Dict: + # return only GUI-editable keys to keep UI simple + return { + "run_mode": self.cfg.get("run_mode", "full"), + "depth": self.cfg["depth"], + "max_nodes": self.cfg["max_nodes"], + "rate_limit_rpm": self.cfg["rate_limit_rpm"], + "skip_private_profiles": self.cfg["skip_private_profiles"], + "include_group_links": self.cfg["include_group_links"], + "include_game_overlap": self.cfg.get("include_game_overlap", False), + "hub_percentile": self.cfg["hub_percentile"], + } + + def set_config(self, partial: Dict) -> Dict: + # Only allow the listed keys to be modified from GUI + self.cfg["run_mode"] = partial.get("run_mode", self.cfg.get("run_mode", "full")) + self.cfg["depth"] = int(partial.get("depth", self.cfg["depth"])) + self.cfg["max_nodes"] = int(partial.get("max_nodes", self.cfg["max_nodes"])) + self.cfg["rate_limit_rpm"] = int( + partial.get("rate_limit_rpm", self.cfg["rate_limit_rpm"]) + ) + self.cfg["skip_private_profiles"] = bool( + partial.get("skip_private_profiles", self.cfg["skip_private_profiles"]) + ) + self.cfg["include_group_links"] = bool( + partial.get("include_group_links", self.cfg["include_group_links"]) + ) + self.cfg["include_game_overlap"] = bool( + partial.get("include_game_overlap", self.cfg.get("include_game_overlap", False)) + ) + self.cfg["hub_percentile"] = float( + partial.get("hub_percentile", self.cfg["hub_percentile"]) + ) + return self.get_config() + + # ----- Presets / Profiles ----- + def apply_preset(self, name: str) -> Dict: + n = (name or "").lower() + if n.startswith("inner"): + self.cfg["depth"] = 1 + self.cfg["max_nodes"] = 300 + elif n.startswith("community"): + self.cfg["depth"] = 2 + self.cfg["max_nodes"] = 500 + return self.get_config() + + def list_profiles(self) -> List[str]: + return [p.stem for p in PROFILES.glob("*.yaml")] + + def load_profile(self, name: str) -> Dict: + path = PROFILES / f"{name}.yaml" + if not path.exists(): + return self.get_config() + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + # Keep only GUI-editable subset + return self.set_config( + { + "run_mode": data.get("run_mode", self.cfg.get("run_mode", "full")), + "depth": data.get("depth", self.cfg["depth"]), + "max_nodes": data.get("max_nodes", self.cfg["max_nodes"]), + "rate_limit_rpm": data.get("rate_limit_rpm", self.cfg["rate_limit_rpm"]), + "skip_private_profiles": data.get( + "skip_private_profiles", self.cfg["skip_private_profiles"] + ), + "include_group_links": data.get( + "include_group_links", self.cfg["include_group_links"] + ), + "include_game_overlap": data.get( + "include_game_overlap", self.cfg.get("include_game_overlap", False) + ), + "hub_percentile": data.get("hub_percentile", self.cfg["hub_percentile"]), + } + ) + + def save_profile(self, name: str, subset: Dict) -> bool: + if not name: + return False + path = PROFILES / f"{name}.yaml" + cfg_subset = self.set_config(subset) # normalize/validate + yaml.safe_dump(cfg_subset, path.open("w", encoding="utf-8"), sort_keys=False) + return True + + # ----- Scan / Estimate ----- + def run_scan(self, payload: Dict) -> Dict: + """ + payload = { target: "steam id/vanity/url" } + """ + target = (payload.get("target") or "").strip() + if not target: + return {"ok": False, "error": "Target is required."} + + mode = self.cfg.get("run_mode", "full") + caps = _mode_caps(mode) + + # Steam API + key = os.getenv("STEAM_API_KEY", "").strip() + api = SteamAPI(key, rpm=self.cfg["rate_limit_rpm"]) + + # Resolve any type of steam identifier + sid = parse_any_steam_input(target, api) + if not sid: + return {"ok": False, "error": "Could not resolve target."} + + out_dir = OUTPUTS / sid / stamp() + out_dir.mkdir(parents=True, exist_ok=True) + logger = RunLogger(out_dir / "run.log") + logger.write(f"Target resolved to SteamID64: {sid}") + + # Scan + logger.write("Scanning network...") + state = scan_network( + api=api, + seed_steamid=sid, + depth=self.cfg["depth"], + rpm=self.cfg["rate_limit_rpm"], + max_nodes=self.cfg["max_nodes"], + skip_private=self.cfg["skip_private_profiles"], + include_group_links=self.cfg["include_group_links"], + resume_state=None, + ) + (out_dir / "scan.json").write_text(json.dumps(state, indent=2), encoding="utf-8") + logger.write("Wrote scan.json") + + nodes_csv = edges_csv = None + if caps["graphi"]: + nodes_csv, edges_csv = export_gephi( + state=state, + out_dir=out_dir, + hub_percentile=self.cfg["hub_percentile"], + ) + logger.write("Wrote graphi files") + + estimates = None + if caps["estimates"]: + # internal defaults (not exposed in GUI) for IRL and location calcs + fopts = FriendProbOpts(top_n_for_mean=5, reasonable_count=50) + lopts = LocationProbOpts(reasonable_count=100, score_strategy="multiply") + estimates = build_estimates(state, fopts, lopts) + (out_dir / "estimates.json").write_text( + json.dumps(estimates, indent=2), encoding="utf-8" + ) + logger.write("Wrote estimates.json") + + logger.write("Done.") + logger.close() + + return { + "ok": True, + "outputDir": str(out_dir), + "nodesCsv": str(nodes_csv) if nodes_csv else None, + "edgesCsv": str(edges_csv) if edges_csv else None, + "estimates": estimates, + "runlog": str(out_dir / "run.log"), + } + + def dry_run(self, target: str) -> Dict: + target = (target or "").strip() + if not target: + return {"ok": False, "error": "Target is required."} + key = os.getenv("STEAM_API_KEY", "").strip() + api = SteamAPI(key, rpm=self.cfg["rate_limit_rpm"]) + sid = parse_any_steam_input(target, api) + if not sid: + return {"ok": False, "error": "Could not resolve target."} + friends = api.get_friend_list(sid) + return {"ok": True, "seed_friends": len(friends)} + + # ----- API key management ----- + def has_api_key(self) -> Dict: + """Report whether a Steam API key is present and where it comes from. + Returns { ok, present, source } where source in { 'file', 'env', 'none' }. + 'file' means .env/.env.example contains a non-placeholder key (persisted), + 'env' means only current environment has the key (not persisted), + 'none' means no key detected. + """ + source = "none" + present = False + # Load files first to prefer persisted keys + file_key_found = False + for p in (ENV_PATH, ENV_EXAMPLE_PATH): + try: + if p.exists(): + txt = p.read_text(encoding="utf-8") + for ln in txt.splitlines(): + s = ln.strip() + if s.startswith("STEAM_API_KEY="): + val = s.split("=", 1)[1].strip().strip("\"'") + if val and val != "your_api_key_here": + file_key_found = True + break + except Exception: + pass + if file_key_found: + source = "file" + present = True + else: + # Fallback to environment + _load_env_file() + if os.getenv("STEAM_API_KEY", "").strip(): + source = "env" + present = True + return {"ok": True, "present": present, "source": source} + + def set_api_key(self, key: str) -> Dict: + key = (key or "").strip().upper() + if not key: + return {"ok": False, "error": "Key is empty."} + # Ensure we operate on .env (rename .env.example if needed) + try: + if ENV_EXAMPLE_PATH.exists() and not ENV_PATH.exists(): + # If the example contains placeholder, we will rename it to .env + ENV_EXAMPLE_PATH.rename(ENV_PATH) + except Exception: + # Fallback: ignore rename errors and just write .env below + pass + # Write/update .env + lines: List[str] = [] + existed = ENV_PATH.exists() + if existed: + try: + lines = ENV_PATH.read_text(encoding="utf-8").splitlines() + except Exception: + lines = [] + updated = False + out_lines: List[str] = [] + for ln in lines: + if ln.strip().startswith("STEAM_API_KEY="): + out_lines.append(f"STEAM_API_KEY={key}") + updated = True + else: + out_lines.append(ln) + if not updated: + out_lines.append(f"STEAM_API_KEY={key}") + ENV_PATH.write_text("\n".join(out_lines) + "\n", encoding="utf-8") + # Set for current process + os.environ["STEAM_API_KEY"] = key + return {"ok": True} + + def validate_api_key(self, key: str) -> Dict: + """Perform a lightweight call that requires a valid key. + Returns {ok: True} if accepted, else {ok: False, error: msg}. + """ + key = (key or "").strip().upper() + # quick format check first + import re + if not re.fullmatch(r"[A-Z0-9]{25,40}", key or ""): + return {"ok": False, "error": "Invalid key format."} + try: + api = SteamAPI(key, rpm=max(1, int(self.cfg.get("rate_limit_rpm", 60)))) + # Known public SteamID64 (any valid one works) + test_id = "76561197960435530" + data = api.get_player_summaries([test_id]) + if data and test_id in data: + return {"ok": True} + return {"ok": False, "error": "Steam rejected the API key."} + except Exception: + return {"ok": False, "error": "Validation call failed."} + + def resolve_target(self, target: str) -> Dict: + """ + Resolve any Steam identifier to steamid64 and return basic profile info + for UI verification (personaname and avatar URL). + + Returns: { ok, steamid64, personaname, avatar } + """ + target = (target or "").strip() + if not target: + return {"ok": False, "error": "Target is required."} + + key = os.getenv("STEAM_API_KEY", "").strip() + if not key: + return {"ok": False, "error": "Steam API key not set."} + + api = SteamAPI(key, rpm=self.cfg["rate_limit_rpm"]) + sid = parse_any_steam_input(target, api) + if not sid: + return {"ok": False, "error": "Could not resolve target."} + + try: + summaries = api.get_player_summaries([sid]) + info = summaries.get(sid, {}) + name = info.get("personaname") or "" + avatar = ( + info.get("avatarfull") + or info.get("avatarmedium") + or info.get("avatar") + or "" + ) + return {"ok": True, "steamid64": sid, "personaname": name, "avatar": avatar} + except Exception: + # Fallback to just returning the resolved sid + return {"ok": True, "steamid64": sid, "personaname": "", "avatar": ""} + + def list_recent(self) -> Dict: + OUTPUTS.mkdir(parents=True, exist_ok=True) + targets = sorted([p for p in OUTPUTS.glob("*") if p.is_dir()], key=lambda p: p.name, reverse=True) + data: List[Dict] = [] + for t in targets: + runs = sorted([r for r in t.glob("*") if r.is_dir()], key=lambda p: p.name, reverse=True) + data.append({"steamid64": t.name, "runs": [r.name for r in runs]}) + return {"ok": True, "items": data} + + def open_outputs(self) -> bool: + try: + OUTPUTS.mkdir(parents=True, exist_ok=True) + open_folder(OUTPUTS) + return True + except Exception: + return False + + def open_run(self, steamid64: str, run: str) -> bool: + try: + path = OUTPUTS / steamid64 / run + if path.exists(): + open_folder(path) + return True + return False + except Exception: + return False + + +def main() -> None: + cfg = yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8")) + bridge = Bridge(cfg) + window = webview.create_window( + "steam-friends-osint", + str((UI_DIR / "index.html").as_uri()), + width=1280, + height=640, + resizable=False, + frameless=True, + easy_drag=False, + js_api=bridge, + ) + # Attach back-reference so JS can call minimize/close via Bridge + bridge.attach(window) + # Center window and enforce height tweak on start + def _center() -> None: + try: + # Ensure final size + window.resize(1280, 640) + # Try to center on primary screen (Windows) + x = y = None + if os.name == "nt": + try: + import ctypes # type: ignore + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + sw = int(user32.GetSystemMetrics(0)) + sh = int(user32.GetSystemMetrics(1)) + x = max(0, (sw - 1280) // 2) + y = max(0, (sh - 700) // 2) + except Exception: + x = y = None + if x is not None and y is not None: + window.move(x, y) + bridge._pos = (x, y) + except Exception: + pass + + # EdgeHTML/Chromium on Windows if available; default elsewhere + webview.start(_center, gui="edgechromium" if os.name == "nt" else None) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/playwright-ui-before.png b/playwright-ui-before.png new file mode 100644 index 0000000..2c8b8a6 Binary files /dev/null and b/playwright-ui-before.png differ diff --git a/playwright-ui-design.png b/playwright-ui-design.png new file mode 100644 index 0000000..8baea20 Binary files /dev/null and b/playwright-ui-design.png differ diff --git a/playwright-ui-design2.png b/playwright-ui-design2.png new file mode 100644 index 0000000..0c701cb Binary files /dev/null and b/playwright-ui-design2.png differ diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..3befcc2 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,1176 @@ +// Minimal pywebview bridge stub for browser preview (no-op promises) +let api = window.pywebview?.api || { + get_config: async () => ({ + run_mode: null, + depth: 2, + max_nodes: 500, + rate_limit_rpm: 120, + skip_private_profiles: true, + include_group_links: true, + include_game_overlap: false, + hub_percentile: 0.99, + }), + set_config: async (cfg) => cfg, + apply_preset: async () => ({}), + run_scan: async () => ({ ok: false, error: "Not running inside app." }), + dry_run: async () => ({ ok: false, error: "Not running inside app." }), + open_outputs: async () => false, + has_api_key: async () => ({ ok: true, present: false }), + set_api_key: async () => ({ ok: true }), + validate_api_key: async () => ({ ok: true }), + list_recent: async () => ({ ok: true, items: [] }), + list_profiles: async () => ([]), + load_profile: async (name) => ({ run_mode: "full", depth: 2, max_nodes: 500, rate_limit_rpm: 120, skip_private_profiles: true, include_group_links: true, include_game_overlap: false, hub_percentile: 0.99 }), + save_profile: async (name, subset) => true, + resolve_target: async (target) => ({ ok: true, personaname: target || "", avatar: "../assets/placeholder.jpg" }), +}; + +/* If running inside pywebview, adopt the real bridge API when it becomes ready + and install observers to keep window sized to content (no scrollbars). */ +function adoptRealApi(){ + if (window.pywebview && window.pywebview.api) { + try { + api = window.pywebview.api; + // Initial tighten to content + scheduleResize?.(); + + // Observe main content area for size changes + const views = document.querySelector('.views'); + if (window.ResizeObserver && views) { + const ro = new ResizeObserver(() => scheduleResize?.()); + ro.observe(views); + } + + // Broadly observe DOM/content changes to keep height in sync + const mo = new MutationObserver(() => scheduleResize?.()); + mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); + } catch {} + } +} +window.addEventListener('pywebviewready', adoptRealApi); + +// Helpers +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); +const logEl = $("#log"); +// Auto-resize helpers to keep window free of scrollbars +function resizeToFit() { + if (!api || !api.win_resize) return; + const bar = document.querySelector('.toolbar'); + const views = document.querySelector('.views'); + if (!bar || !views) return; + const totalH = Math.ceil(bar.getBoundingClientRect().height + views.scrollHeight); + const targetH = Math.max(400, totalH + 6); + try { api.win_resize(1280, targetH); } catch {} +} +let __vaporaResizeRAF = 0; +function scheduleResize() { + if (window.__suspendAutoResize || document.body?.classList?.contains('design-mode')) return; + if (typeof requestAnimationFrame !== 'function') { resizeToFit(); return; } + if (__vaporaResizeRAF) cancelAnimationFrame(__vaporaResizeRAF); + __vaporaResizeRAF = requestAnimationFrame(() => { + __vaporaResizeRAF = 0; + resizeToFit(); + }); +} +function log(msg) { + if (!logEl) return; + logEl.textContent += `\n${msg}`; + logEl.scrollTop = logEl.scrollHeight; + try { scheduleResize(); } catch {} +} + +function selectTab(name) { + $$(".nav-link").forEach((l) => l.classList.remove("active")); + $$(".view").forEach((v) => v.classList.remove("active")); + $(`.nav-link[data-tab="${name}"]`)?.classList.add("active"); + $(`#view-${name}`)?.classList.add("active"); + try { scheduleResize(); } catch {} +} + +// Toast utility +function showToast(message, timeout = 1800){ + const el = document.getElementById('toast'); + if (!el) return; + el.textContent = message; + el.hidden = false; + try { scheduleResize(); } catch {} + clearTimeout(showToast._t); + showToast._t = setTimeout(()=>{ el.hidden = true; try { scheduleResize(); } catch {} }, timeout); +} + +// Tab switching +$$(".nav-link[data-tab]").forEach((link) => { + link.addEventListener("click", (e) => { + e.preventDefault(); + selectTab(link.dataset.tab); + }); +}); + +// Depth toggle (visual only; applied when clicking Apply) +$$(".btn-depth").forEach((btn) => { + btn.addEventListener("click", () => { + $$(".btn-depth").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + }); +}); + +// Mode toggle -> push run_mode immediately +$$(".btn-mode").forEach((btn) => { + btn.addEventListener("click", async () => { + const isActive = btn.classList.contains("active"); + // Always clear all button states first + $$(".btn-mode").forEach((b) => b.classList.remove("active")); + + let run_mode = null; + if (!isActive) { + // If it wasn't active, we now activate it. + btn.classList.add("active"); + run_mode = btn.dataset.mode; + } + // If it was active, it remains deactivated and run_mode is null. + + try { + await api.set_config({ run_mode: run_mode || '' }); // Send empty string for "none" + log(run_mode ? `[Mode] ${btn.textContent.trim()} (${run_mode})` : "[Mode] None"); + applyModeColors(run_mode); + } catch (e) { + log(`[Error] set_config(run_mode): ${e}`); + } + }); +}); + +function readUI() { + const depth = parseInt($(".btn-depth.active")?.dataset.depth || "2", 10); + const max_nodes = parseInt($("#nodes")?.value || "", 10) || undefined; + const rate_limit_rpm = parseInt($("#rpm")?.value || "", 10) || undefined; + const skip_private_profiles = $("#cb-skip-private")?.checked ?? true; + const include_group_links = $("#cb-groups")?.checked ?? true; + const include_game_overlap = $("#cb-games")?.checked ?? false; + return { + depth, + ...(max_nodes ? { max_nodes } : {}), + ...(rate_limit_rpm ? { rate_limit_rpm } : {}), + skip_private_profiles, + include_group_links, + include_game_overlap, + }; +} + +function applyConfigUI(cfg) { + // Depth + const d = cfg.depth ?? 2; + const depthBtn = $(`.btn-depth[data-depth="${d}"]`); + if (depthBtn) { + $$(".btn-depth").forEach((b) => b.classList.remove("active")); + depthBtn.classList.add("active"); + } + // Checkboxes + if ("skip_private_profiles" in cfg && $("#cb-skip-private")) + $("#cb-skip-private").checked = !!cfg.skip_private_profiles; + if ("include_group_links" in cfg && $("#cb-groups")) + $("#cb-groups").checked = !!cfg.include_group_links; + if ("include_game_overlap" in cfg && $("#cb-games")) + $("#cb-games").checked = !!cfg.include_game_overlap; + // Text inputs + if (cfg.max_nodes && $("#nodes")) $("#nodes").value = String(cfg.max_nodes); + if (cfg.rate_limit_rpm && $("#rpm")) $("#rpm").value = String(cfg.rate_limit_rpm); + // Mode buttons + $$(".btn-mode").forEach((b) => b.classList.remove("active")); + const mode = cfg.run_mode ? cfg.run_mode.toLowerCase() : null; + if (mode) { + const modeBtn = $(`.btn-mode[data-mode="${mode}"]`); + if(modeBtn) modeBtn.classList.add("active"); + } +} + +// Apply button -> set_config +$("#apply-btn")?.addEventListener("click", async () => { + const partial = readUI(); + try { + const newCfg = await api.set_config(partial); + applyConfigUI(newCfg); + applyModeColors(newCfg.run_mode || "full"); + log( + `[Applied] Depth=${newCfg.depth}, Nodes=${newCfg.max_nodes}, RPM=${newCfg.rate_limit_rpm}, ` + + `SkipPrivate=${newCfg.skip_private_profiles}, Groups=${newCfg.include_group_links}, Games=${newCfg.include_game_overlap}` + ); + } catch (e) { + log(`[Error] set_config: ${e}`); + } +}); + +// Open output folder via Bridge +$("#open-output")?.addEventListener("click", async () => { + try { + await api.open_outputs(); + } catch (e) { + log(`[Error] open_outputs: ${e}`); + } +}); + +// Window control events for frameless window +document.getElementById('btn-win-min')?.addEventListener('click', async () => { + try { await api.win_minimize(); } catch {} +}); +document.getElementById('btn-win-close')?.addEventListener('click', async () => { + try { await api.win_close(); } catch {} +}); + +// Manual drag fallback: only allow toolbar background to initiate movement +(function setupManualDrag(){ + const bar = document.querySelector('.toolbar'); + if (!bar) return; + let dragging = false; + let lastX = 0, lastY = 0; + let raf = 0; + let accumDX = 0, accumDY = 0; + + const isInteractive = (el) => !!el.closest('.window-controls, .tabs, .button, .icon-btn, .align-icon, #btn-key'); + + const onMouseDown = (e) => { + // start only when pressing toolbar background (not on its buttons/links) + if (isInteractive(e.target)) return; + dragging = true; + lastX = e.screenX; + lastY = e.screenY; + accumDX = 0; accumDY = 0; + e.preventDefault(); + }; + const applyMove = () => { + if (!dragging) return; + const dx = accumDX; const dy = accumDY; + accumDX = 0; accumDY = 0; + if (dx || dy) { try { api.win_move_by(dx, dy); } catch {} } + raf = dragging ? requestAnimationFrame(applyMove) : 0; + }; + const onMouseMove = (e) => { + if (!dragging) return; + accumDX += (e.screenX - lastX); + accumDY += (e.screenY - lastY); + lastX = e.screenX; + lastY = e.screenY; + if (!raf) raf = requestAnimationFrame(applyMove); + }; + const onMouseUp = () => { + dragging = false; + if (raf) cancelAnimationFrame(raf); raf = 0; + }; + + bar.addEventListener('mousedown', onMouseDown); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); +})(); + +// Autodetect target: debounce resolve_target on input +let _verifyTimer = null; +async function verifyAndFill() { + if (!window.__apiReady) return; + const target = document.getElementById('target')?.value?.trim(); + if (!target) { + const nameEl = document.getElementById('target-username'); + if (nameEl) nameEl.textContent = ''; + const img = document.querySelector('.target-avatar img'); + if (img) img.src = '../assets/placeholder.jpg'; + return; + } + try { + const fn = api.resolve_target || (async (t)=>({ ok:true, personaname:t, avatar:'../assets/placeholder.jpg' })); + const res = await fn(target); + if (!res?.ok) { + log(`[Warn] Could not resolve target '${target}'.`); + return; + } + const name = res.personaname || target; + const avatar = res.avatar || '../assets/placeholder.jpg'; + const nameEl = document.getElementById('target-username'); + if (nameEl) nameEl.textContent = name; + const img = document.querySelector('.target-avatar img'); + if (img) img.src = avatar; + try { scheduleResize(); } catch {} + } catch (e) { + log(`[Error] resolve_target failed: ${e}`); + } +} +document.getElementById('target')?.addEventListener('input', () => { + if (_verifyTimer) clearTimeout(_verifyTimer); + _verifyTimer = setTimeout(verifyAndFill, 600); +}); +document.getElementById('target')?.addEventListener('blur', () => { + if (_verifyTimer) clearTimeout(_verifyTimer); + _verifyTimer = setTimeout(verifyAndFill, 50); +}); +$("#btn-analyze")?.addEventListener("click", async () => { + const target = $("#target")?.value?.trim(); + if (!target) { + log("[Warn] Enter a target first."); + return; + } + log(`[Run] Scanning ${target}…`); + try { + const res = await api.run_scan({ target }); + if (!res?.ok) { + log(`[Error] ${res?.error || "run_scan failed"}`); + return; + } + // Update output tree preview + const out = res.outputDir || "/"; + const tree = ` +${out.replace(/\\/g, "/")}/ +├─ gephi/ +│ ├─ nodes.csv +│ └─ edges.csv +├─ estimates.json +├─ scan.json +└─ run.log`; + const outEl = $("#output-tree"); + if (outEl) outEl.innerHTML = tree; + applyModeColors($(".btn-mode.active")?.dataset.mode || null); + + // Results list + const items = $("#result-items"); + if (items) { + items.innerHTML = ""; + if (res.nodesCsv) { + const li = document.createElement("li"); + li.textContent = `nodes.csv → ${res.nodesCsv}`; + items.appendChild(li); + } + if (res.edgesCsv) { + const li = document.createElement("li"); + li.textContent = `edges.csv → ${res.edgesCsv}`; + items.appendChild(li); + } + if (res.estimates) { + const li = document.createElement("li"); + const cf = res.estimates.closestFriends?.slice(0, 5) || []; + li.textContent = `closestFriends(top5): ${cf.map((x) => x.personaname || x.name || x.steamid).join(", ")}`; + items.appendChild(li); + } + } + try { scheduleResize(); } catch {} + log(`[OK] Output → ${res.outputDir}`); + // Switch to Results tab + selectTab("results"); + } catch (e) { + log(`[Error] run_scan exception: ${e}`); + } +}); + +// API key modal and lock overlay +function openKeyModal(){ + const modal = document.getElementById('key-modal'); + const input = document.getElementById('api-key-input'); + if (!modal || !input) return; + input.value = ''; + modal.hidden = false; + setTimeout(()=>{ input.focus(); }, 20); +} +function closeKeyModal(){ const modal = document.getElementById('key-modal'); if (modal) modal.hidden = true; } +document.getElementById('btn-key')?.addEventListener('click', async () => { + try { + const s = await api.has_api_key(); + // Only warn about replacement if a persisted key exists in a file + if (s && s.present && (s.source === 'file')) { + const m = document.getElementById('key-note-modal'); + if (m) { m.hidden = false; return; } + } + } catch {} + openKeyModal(); +}); +document.getElementById('api-key-cancel')?.addEventListener('click', closeKeyModal); +document.getElementById('api-key-save')?.addEventListener('click', async () => { + const val = document.getElementById('api-key-input')?.value?.trim(); + const errEl = document.getElementById('api-key-error'); + const key = (val || '').toUpperCase(); + const validFormat = /^[A-Z0-9]{25,40}$/.test(key); + if (!validFormat){ + if (errEl){ errEl.textContent = 'Invalid API key format.'; errEl.hidden = false; } + return; + } else if (errEl){ errEl.hidden = true; } + try{ + const check = await (api.validate_api_key ? api.validate_api_key(key) : { ok: true }); + if (!check?.ok){ + if (errEl){ errEl.textContent = check?.error || 'Steam rejected the API key.'; errEl.hidden = false; } + return; + } + const res = await api.set_api_key(key); + if (!res?.ok){ showToast(res?.error || 'Failed to save key'); return; } + window.__apiReady = true; + const lock = document.getElementById('lock-overlay'); + if (lock) lock.hidden = true; + const hint = document.getElementById('api-key-hint'); + if (hint) hint.hidden = true; + closeKeyModal(); + showToast('API key saved.'); + verifyAndFill(); + }catch(e){ showToast('Error saving key'); } +}); + +// Live format validation feedback +document.getElementById('api-key-input')?.addEventListener('input', (e) => { + const t = e.target?.value?.trim() || ''; + const key = t.toUpperCase(); + const errEl = document.getElementById('api-key-error'); + if (!errEl) return; + if (!key){ errEl.hidden = true; return; } + const valid = /^[A-Z0-9]{25,40}$/.test(key); + errEl.textContent = valid ? '' : 'Invalid API key format.'; + errEl.hidden = !!valid; +}); +// Note modal actions -> proceed to key entry or cancel +document.getElementById('key-note-ok')?.addEventListener('click', () => { + const m = document.getElementById('key-note-modal'); + if (m) m.hidden = true; + openKeyModal(); +}); +document.getElementById('key-note-cancel')?.addEventListener('click', () => { + const m = document.getElementById('key-note-modal'); + if (m) m.hidden = true; +}); +document.getElementById('lock-overlay')?.addEventListener('click', () => { + showToast('API key required. Press the key icon to set it.'); +}); + +// Estimate only (dry-run) +$("#btn-estimate")?.addEventListener("click", async () => { + const target = $("#target")?.value?.trim(); + if (!target) { + log("[Warn] Enter a target first."); + return; + } + try { + const res = await api.dry_run(target); + if (!res?.ok) { + log(`[Error] ${res?.error || "dry_run failed"}`); + return; + } + log(`[Estimate] Seed friends ~ ${res.seed_friends}`); + selectTab("results"); + } catch (e) { + log(`[Error] dry_run exception: ${e}`); + } +}); + +// Save / Load profile via simple prompts on the vertical rail +document.getElementById('btn-load')?.addEventListener('click', async () => { + try { + const names = await api.list_profiles(); + const hint = Array.isArray(names) && names.length ? `Available: ${names.join(', ')}` : 'Enter profile name'; + const name = window.prompt(`Load profile\n${hint}`, names?.[0] || 'default'); + if (!name) return; + const cfg = await api.load_profile(name); + applyConfigUI(cfg); + applyModeColors(cfg.run_mode || 'full'); + log(`[Profile] Loaded '${name}'`); + } catch (e) { + log(`[Error] load_profile: ${e}`); + } +}); + +document.getElementById('btn-save')?.addEventListener('click', async () => { + try { + const name = window.prompt('Save profile as', 'default'); + if (!name) return; + const subset = { ...readUI(), run_mode: document.querySelector('.btn-mode.active')?.dataset.mode || 'full' }; + const ok = await api.save_profile(name, subset); + if (ok) log(`[Profile] Saved '${name}'`); + } catch (e) { + log(`[Error] save_profile: ${e}`); + } +}); + +// Initial config load +(async () => { + // If running under pywebview, pick up the real bridge API now + if (window.pywebview && window.pywebview.api) { api = window.pywebview.api; } + try { + const cfg = await api.get_config(); + applyConfigUI(cfg); + applyModeColors(cfg.run_mode); + log("Loaded config."); + } catch (e) { + log(`[Error] get_config: ${e}`); + } + // API key presence -> lock overlay + try { + const s = await api.has_api_key(); + window.__apiReady = !!(s?.present); + } catch { window.__apiReady = false; } + const lock = document.getElementById('lock-overlay'); + if (lock) lock.hidden = !!window.__apiReady; + const hint = document.getElementById('api-key-hint'); + if (hint) hint.hidden = !!window.__apiReady; + // Populate recent thumbnails + try { + const list = await api.list_recent(); + const host = document.querySelector('#recent-thumbs'); + if (host && list?.ok) { + host.innerHTML = ''; + const items = (list.items || []).slice(0, 6); + if (!items.length) { + for (let i = 0; i < 4; i++) { + const div = document.createElement('div'); + div.className = 'recent-thumb'; + const img = document.createElement('img'); + img.src = '../assets/placeholder.jpg'; + img.alt = 'recent'; + div.appendChild(img); + host.appendChild(div); + } + } else { + items.forEach((it) => { + const div = document.createElement('div'); + div.className = 'recent-thumb'; + div.title = it.steamid64; + const img = document.createElement('img'); + img.src = '../assets/placeholder.jpg'; + img.alt = it.steamid64; + div.appendChild(img); + div.addEventListener('click', () => { + const input = document.querySelector('#target'); + if (input) input.value = it.steamid64; + }); + host.appendChild(div); + }); + } + } + try { scheduleResize(); } catch {} + } catch {} +})(); + + // Mode-based coloring of the output tree +function applyModeColors(mode){ + // Clear all previous active states first + document.querySelectorAll('#output-tree span').forEach(el => el.classList.remove('tree-active')); + + if (!mode) return; // If mode is null/undefined, do nothing else. + + const keys = { + root: document.querySelector('[data-key="root"]'), + gephi: document.querySelector('[data-key="gephi"]'), + nodes: document.querySelector('[data-key="nodes"]'), + edges: document.querySelector('[data-key="edges"]'), + estimates: document.querySelector('[data-key="estimates"]'), + scan: document.querySelector('[data-key="scan"]'), + runlog: document.querySelector('[data-key="runlog"]'), + }; + + const setActive = (arr) => { + arr.forEach((k) => { + const el = keys[k]; + if (el) { + el.classList.add('tree-active'); + // Also activate the preceding tree branch character if it exists + let prev = el.previousElementSibling; + while(prev && prev.classList.contains('tree-branch')) { + prev.classList.add('tree-active'); + prev = prev.previousElementSibling; + } + } + }); + }; + + const m = mode.toLowerCase(); + if (m === 'full') { // Deep + setActive(['root','gephi','nodes','edges','estimates','scan','runlog']); + } else if (m === 'basic') { // Surface + setActive(['root', 'estimates','runlog']); + } else if (m === 'gephi') { // gephi + setActive(['root', 'gephi','nodes','edges']); + } +} + + + // Lightweight UI design tool (direct-manipulation, grid-snapped) +(function setupDesignTool(){ + const panel = document.getElementById('design-panel'); + if (!panel) return; + + // Panel elements + const gridInput = document.getElementById('design-grid'); + const snapInput = document.getElementById('design-snap'); + const infoSpan = document.getElementById('design-info'); + const btnCopy = document.getElementById('design-copy'); + const btnCopyAll = document.getElementById('design-copy-all'); + const btnApply = document.getElementById('design-apply'); + const btnExit = document.getElementById('design-exit'); + const scopeSelect = document.getElementById('design-scope'); + const customInput = document.getElementById('design-custom'); + const btnAll = document.getElementById('design-all'); + const btnClear = document.getElementById('design-clear'); + + let enabled = false; + let currentContainer = null; + const selected = new Set(); // Set + let active = null; // { type:'move'|'resize', targets:Set, base:Array<{el,left,top,width,height}>, startX, startY } + let capture = { x:0, y:0 }; + let nudging = false; + const frozen = new Set(); + const lockBounds = false; + + function clampMove(left, top, el){ + if (!lockBounds) return { left, top }; + const c = getContainer(); + const cw = c?.clientWidth ?? 1e9; + const ch = c?.clientHeight ?? 1e9; + const r = el.getBoundingClientRect(); + const w = Math.round(r.width); + const h = Math.round(r.height); + return { + left: Math.max(0, Math.min(left, cw - w)), + top: Math.max(0, Math.min(top, ch - h)), + }; + } + + function clampBox(edge, left, top, width, height, el){ + if (!lockBounds) return { left, top, width, height }; + const c = getContainer(); + const cw = c?.clientWidth ?? 1e9; + const ch = c?.clientHeight ?? 1e9; + + const MIN = 16; + width = Math.max(MIN, width); + height = Math.max(MIN, height); + + if (left < 0){ + if (edge.includes('w')) width += left; + left = 0; + } + if (top < 0){ + if (edge.includes('n')) height += top; + top = 0; + } + if (left + width > cw){ + if (edge.includes('e')) width = cw - left; + else left = Math.max(0, cw - width); + } + if (top + height > ch){ + if (edge.includes('s')) height = ch - top; + else top = Math.max(0, ch - height); + } + + width = Math.max(MIN, width); + height = Math.max(MIN, height); + return { left, top, width, height }; + } + + // Undo history (array of snapshots) + const history = []; + function captureSnapshot(){ + const list = Array.from(getContainer()?.querySelectorAll(scopeSelector())||[]); + return list.map((el, idx)=>({ + el, + id: el.id || el.dataset.label || `${el.tagName.toLowerCase()}#${idx}`, + left: el.style.left || '', + top: el.style.top || '', + width: el.style.width || '', + height: el.style.height || '' + })); + } + function pushHistory(){ + try { + history.push(captureSnapshot()); + if (history.length > 120) history.shift(); + } catch {} + } + function restoreSnapshot(snap){ + if (!snap) return; + snap.forEach(item=>{ + const el = item.el; + if (!el) return; + ensureAbsolute(el); + if (item.left) el.style.left = item.left; + if (item.top) el.style.top = item.top; + if (item.width) el.style.width = item.width; + if (item.height) el.style.height = item.height; + }); + updateInfo(); + showToast?.('Undo'); + } + + const getContainer = () => document.querySelector('.view.active') || document.querySelector('.views'); + const getContainerRect = () => (getContainer()?.getBoundingClientRect() || { left:0, top:0, width:0, height:0 }); + + function getGrid(){ return Math.max(1, parseInt(gridInput?.value||'8',10)); } + function snap(n){ if (!snapInput?.checked) return Math.round(n); const g = getGrid(); return Math.round(n/g)*g; } + + function scopeSelector(){ + const v = scopeSelect?.value || 'auto'; + if (v==='auto') return '[data-designable]'; + if (v==='panels') return '.panel'; + if (v==='rows') return '.param-row, .mode-output-row, .target-top, .target-username, .target-avatar, .output-panel, .mode-panel, .results-panel, .results-log, .results-list'; + if (v==='controls') return 'button, .button, input, select, .btn-mode, .btn-depth, .rail-btn, .icon-btn'; + if (v==='all') return '*'; + if (v==='custom') return customInput?.value?.trim() || '[data-designable]'; + return '[data-designable]'; + } + function inScope(el){ + try { return !!el.closest(scopeSelector()); } catch { return false; } + } + + function ensureAbsolute(el){ + const container = getContainer(); + const cr = getContainerRect(); + const r = el.getBoundingClientRect(); + if (!container) return; + // Save previous inline style once + if (!el.dataset.designPrev){ + const prev = { + position: el.style.position || '', + left: el.style.left || '', + top: el.style.top || '', + width: el.style.width || '', + height: el.style.height || '', + gridArea: el.style.gridArea || '', + gridColumn: el.style.gridColumn || '', + gridRow: el.style.gridRow || '', + flex: el.style.flex || '', + alignSelf: el.style.alignSelf || '', + justifySelf: el.style.justifySelf || '', + }; + el.dataset.designPrev = JSON.stringify(prev); + } + // Anchor container for abs children + if (getComputedStyle(container).position === 'static'){ + container.style.position = 'relative'; + container.dataset.designPos = '1'; + } + // Clear conflicting layout props + ['gridArea','gridColumn','gridRow','flex','alignSelf','justifySelf'].forEach(p=>{ + try { el.style[p] = ''; } catch {} + }); + // Remove size limits and clipping that can cap or hide elements while resizing + try { + el.style.minWidth = '0px'; + el.style.minHeight = '0px'; + el.style.maxWidth = 'none'; + el.style.maxHeight = 'none'; + el.style.overflow = 'visible'; + el.style.boxSizing = 'border-box'; + // Bring above siblings while designing + const z = parseInt(el.style.zIndex || '0', 10) || 0; + el.style.zIndex = String(Math.max(1000, z)); + } catch {} + el.style.position = 'absolute'; + const op = el.offsetParent || container; + const pr = (op && op.getBoundingClientRect) ? op.getBoundingClientRect() : { left: 0, top: 0 }; + const leftPx = Math.round(r.left - pr.left + (op?.scrollLeft || 0)); + const topPx = Math.round(r.top - pr.top + (op?.scrollTop || 0)); + el.style.left = `${leftPx}px`; + el.style.top = `${topPx}px`; + el.style.width = `${Math.round(r.width)}px`; + el.style.height = `${Math.round(r.height)}px`; + } + + function updateInfo(){ + if (!infoSpan) return; + if (!selected.size){ infoSpan.textContent = ''; return; } + if (selected.size === 1){ + const el = [...selected][0]; + const left = parseInt(el.style.left||'0',10); + const top = parseInt(el.style.top||'0',10); + const w = parseInt(el.style.width||`${Math.round(el.getBoundingClientRect().width)}`,10); + const h = parseInt(el.style.height||`${Math.round(el.getBoundingClientRect().height)}`,10); + const label = el.dataset.label || el.id || el.tagName.toLowerCase(); + infoSpan.textContent = `${label} → x:${left}, y:${top}, w:${w}, h:${h}`; + } else { + infoSpan.textContent = `${selected.size} selected`; + } + } + + function updateSelectionStyles(){ + const list = Array.from(getContainer()?.querySelectorAll(scopeSelector())||[]); + list.forEach(el=>{ + el.style.outline = ''; + el.style.outlineOffset = ''; + el.style.filter = ''; + }); + selected.forEach(el=>{ + try { + el.style.outline = '2px dashed #4DA3FF'; + el.style.outlineOffset = '0px'; + el.style.filter = 'drop-shadow(0 0 0.5px #4DA3FF)'; + } catch {} + }); + } + + function selectOnly(el){ + selected.clear(); + if (el) selected.add(el); + updateSelectionStyles(); + updateInfo(); + } + function toggleSelect(el){ + if (selected.has(el)) selected.delete(el); + else selected.add(el); + updateSelectionStyles(); + updateInfo(); + } + function clearSelection(){ + selected.clear(); + updateSelectionStyles(); + updateInfo(); + } + + // Freeze only currently selected elements to avoid collapsing the whole UI + function freezeLayout(){ + const container = getContainer(); + if (!container) return; + const list = selected.size ? [...selected] : []; + list.forEach(el => { + if (panel && panel.contains(el)) return; + ensureAbsolute(el); + frozen.add(el); + }); + } + + // Restore all elements back to their previous inline styles + function restoreLayout(){ + const container = getContainer(); + try { + frozen.forEach(el=>{ + try { + const prev = el.dataset.designPrev ? JSON.parse(el.dataset.designPrev) : {}; + [ + 'position','left','top','width','height', + 'gridArea','gridColumn','gridRow','flex','alignSelf','justifySelf', + 'zIndex','overflow','boxSizing','minWidth','minHeight','maxWidth','maxHeight', + 'outline','outlineOffset','filter' + ].forEach(p=>{ try { el.style[p] = (prev[p] ?? ''); } catch {} }); + delete el.dataset.designPrev; + } catch {} + }); + frozen.clear(); + if (container && container.dataset && container.dataset.designPos){ + container.style.position = ''; + delete container.dataset.designPos; + } + } catch {} + updateSelectionStyles(); + updateInfo(); + } + + function beginInteraction(e, type){ + const target = e.target.closest(scopeSelector()); + if (!target) return; + if (!selected.has(target) && !(e.ctrlKey||e.metaKey)) selectOnly(target); + if ((e.ctrlKey||e.metaKey) && !selected.has(target)) toggleSelect(target); + // Snapshot BEFORE change for undo + pushHistory(); + // Ensure all selected are absolutely positioned and track for restore + [...selected].forEach(el=> { ensureAbsolute(el); frozen.add(el); }); + // Determine resize edge/corner near the pointer for single selection + let edge = 'se'; + if (type==='resize' && selected.size===1){ + const r = target.getBoundingClientRect(); + const px = e.clientX, py = e.clientY; + const near = 14; + const leftEdge = (px - r.left) <= near; + const rightEdge = (r.right - px) <= near; + const topEdge = (py - r.top) <= near; + const bottomEdge = (r.bottom - py) <= near; + if (topEdge && leftEdge) edge = 'nw'; + else if (topEdge && rightEdge) edge = 'ne'; + else if (bottomEdge && leftEdge) edge = 'sw'; + else if (bottomEdge && rightEdge) edge = 'se'; + else if (leftEdge) edge = 'w'; + else if (rightEdge) edge = 'e'; + else if (topEdge) edge = 'n'; + else if (bottomEdge) edge = 's'; + } + const base = [...selected].map(el=>({ + el, + left: parseInt(el.style.left||'0',10), + top: parseInt(el.style.top||'0',10), + width: parseInt(el.style.width||`${Math.round(el.getBoundingClientRect().width)}`,10), + height: parseInt(el.style.height||`${Math.round(el.getBoundingClientRect().height)}`,10), + })); + active = { type, edge, targets: new Set(selected), base, startX: e.clientX, startY: e.clientY }; + capture.x = e.clientX; capture.y = e.clientY; + e.preventDefault(); + } + + function onDocDown(e){ + if (!enabled) return; + if (!inScope(e.target)) return; + // Alt+Drag moves, Alt+Shift resizes + if (e.altKey){ + const type = e.shiftKey ? 'resize' : 'move'; + beginInteraction(e, type); + } else { + // simple selection without modifiers + const el = e.target.closest(scopeSelector()); + if (el && !panel.contains(e.target)){ + if (e.ctrlKey||e.metaKey) toggleSelect(el); + else selectOnly(el); + } + } + } + + function onDocMove(e){ + if (!enabled || !active) return; + const dx = e.clientX - active.startX; + const dy = e.clientY - active.startY; + active.base.forEach(b=>{ + if (active.type==='move'){ + let nx = snap(b.left + dx); + let ny = snap(b.top + dy); + const pos = clampMove(nx, ny, b.el); + b.el.style.left = `${pos.left}px`; + b.el.style.top = `${pos.top}px`; + } else { // edge/corner-aware resize + const MIN = 16; + const edge = active.edge || 'se'; + let left = b.left, top = b.top, width = b.width, height = b.height; + + if (edge.includes('w')) { left = snap(b.left + dx); width = snap(b.width - dx); } + if (edge.includes('e')) { width = snap(b.width + dx); } + if (edge.includes('n')) { top = snap(b.top + dy); height = snap(b.height - dy); } + if (edge.includes('s')) { height = snap(b.height + dy); } + + if (width < MIN){ + if (edge.includes('w')) left += (width - MIN); + width = MIN; + } + if (height < MIN){ + if (edge.includes('n')) top += (height - MIN); + height = MIN; + } + + const box = clampBox(edge, left, top, width, height, b.el); + b.el.style.left = `${box.left}px`; + b.el.style.top = `${box.top}px`; + b.el.style.width = `${box.width}px`; + b.el.style.height = `${box.height}px`; + } + }); + updateInfo(); + e.preventDefault(); + } + function onDocUp(){ + if (!enabled) return; + if (active){ // interaction finished -> snapshot AFTER change for undo + pushHistory(); + } + active = null; + } + + function applySelection(){ + if (!selected.size) return; + [...selected].forEach(el=> ensureAbsolute(el)); + pushHistory(); + showToast?.('Applied to elements'); + } + + function toJSON(all = false){ + const out = {}; + const list = (all || !selected.size) + ? Array.from(getContainer()?.querySelectorAll('[data-label]') || []) + : [...selected]; + list.forEach((el, idx)=>{ + const id = el.id || el.dataset.label || `${el.tagName.toLowerCase()}#${idx}`; + out[id] = { + x: parseInt(el.style.left||'0',10), + y: parseInt(el.style.top||'0',10), + w: parseInt(el.style.width||`${Math.round(el.getBoundingClientRect().width)}`,10), + h: parseInt(el.style.height||`${Math.round(el.getBoundingClientRect().height)}`,10), + label: el.dataset.label || id + }; + }); + return JSON.stringify(out, null, 2); + } + + function enable(on){ + enabled = !!on; + panel.hidden = !enabled; + document.body.classList.toggle('design-mode', enabled); + // Ensure lock overlay does not block interactions in design mode and show quick help + try { + const lock = document.getElementById('lock-overlay'); + if (enabled) { + if (lock) { lock.dataset.prevPe = lock.style.pointerEvents || ''; lock.style.pointerEvents = 'none'; } + if (infoSpan) infoSpan.textContent = 'Alt+Drag move | Alt+Shift drag / Alt+Shift Arrows resize | Ctrl+Z undo'; + document.body.dataset.prevOverflow = document.body.style.overflow || ''; + document.body.style.overflow = 'visible'; + } else { + if (lock && lock.dataset && 'prevPe' in lock.dataset) { lock.style.pointerEvents = lock.dataset.prevPe; delete lock.dataset.prevPe; } + if (infoSpan) infoSpan.textContent = ''; + if (document.body.dataset && 'prevOverflow' in document.body.dataset) { + document.body.style.overflow = document.body.dataset.prevOverflow; + delete document.body.dataset.prevOverflow; + } + } + } catch {} + // Allow interacting with UI while holding Alt to move/resize + if (enabled){ + currentContainer = getContainer(); + try { + if (currentContainer) { + if (!currentContainer.dataset.prevOverflow) currentContainer.dataset.prevOverflow = currentContainer.style.overflow || ''; + currentContainer.style.overflow = 'visible'; + } + } catch {} + window.addEventListener('mousedown', onDocDown, true); + window.addEventListener('mousemove', onDocMove, true); + window.addEventListener('mouseup', onDocUp, true); + // Pin container size to prevent collapse while editing + try { + const cr = currentContainer?.getBoundingClientRect(); + if (currentContainer && cr) { + if (!currentContainer.dataset.prevMinW) currentContainer.dataset.prevMinW = currentContainer.style.minWidth || ''; + if (!currentContainer.dataset.prevMinH) currentContainer.dataset.prevMinH = currentContainer.style.minHeight || ''; + currentContainer.style.minWidth = `${Math.round(cr.width)}px`; + const h = Math.max(cr.height, currentContainer.scrollHeight || 0); + currentContainer.style.minHeight = `${Math.round(h)}px`; + } + } catch {} + // Initial baseline snapshot + pushHistory(); + } else { + window.removeEventListener('mousedown', onDocDown, true); + window.removeEventListener('mousemove', onDocMove, true); + window.removeEventListener('mouseup', onDocUp, true); + try { + if (currentContainer && currentContainer.dataset && 'prevOverflow' in currentContainer.dataset){ + currentContainer.style.overflow = currentContainer.dataset.prevOverflow; + delete currentContainer.dataset.prevOverflow; + } + if (currentContainer && currentContainer.dataset && 'prevMinW' in currentContainer.dataset){ + currentContainer.style.minWidth = currentContainer.dataset.prevMinW; + delete currentContainer.dataset.prevMinW; + } + if (currentContainer && currentContainer.dataset && 'prevMinH' in currentContainer.dataset){ + currentContainer.style.minHeight = currentContainer.dataset.prevMinH; + delete currentContainer.dataset.prevMinH; + } + } catch {} + restoreLayout(); + clearSelection(); + } + try { window.__suspendAutoResize = !!enabled; } catch {} + try { scheduleResize?.(); } catch {} + } + + // Panel buttons + btnExit?.addEventListener('click', ()=> enable(false)); + btnApply?.addEventListener('click', applySelection); + btnCopy?.addEventListener('click', async ()=>{ + const text = toJSON(); + try { await navigator.clipboard.writeText(text); showToast?.('Copied layout JSON'); } + catch { try { window.prompt('Layout JSON (copy):', text); } catch {} } + }); + btnCopyAll?.addEventListener('click', async ()=>{ + const text = toJSON(true); + try { await navigator.clipboard.writeText(text); showToast?.('Copied all layout JSON'); } + catch { try { window.prompt('Layout JSON (copy):', text); } catch {} } + }); + btnAll?.addEventListener('click', ()=>{ + const list = Array.from(getContainer()?.querySelectorAll(scopeSelector())||[]); + selected.clear(); + list.forEach(el=> selected.add(el)); + updateInfo(); + }); + btnClear?.addEventListener('click', clearSelection); + + // Toolbar toggle + document.getElementById('btn-design')?.addEventListener('click', (e)=>{ + e.preventDefault(); + enable(!enabled); + }); + + const btnLayout = document.getElementById('btn-layout'); + const updateLayoutButton = () => { + if (!btnLayout) return; + const on = document.body.classList.contains('layout-abs'); + btnLayout.textContent = on ? 'Coords: Off' : 'Coords: On'; + }; + + btnLayout?.addEventListener('click', (e) => { + e.preventDefault(); + const on = !document.body.classList.contains('layout-abs'); + document.body.classList.toggle('layout-abs', on); + updateLayoutButton(); + showToast?.(on ? 'Coordinate layout ON' : 'Coordinate layout OFF'); + try { scheduleResize?.(); } catch {} + }); + + // Keyboard shortcuts + window.addEventListener('keydown', (e)=>{ + // Toggle design + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase()==='d'){ + e.preventDefault(); enable(!enabled); return; + } + // Undo + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase()==='z'){ + if (enabled && history.length > 1){ + e.preventDefault(); + // Drop current state then restore previous + history.pop(); + restoreSnapshot(history[history.length - 1]); + } + return; + } + if (!enabled) return; + // Select all + if ((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='a'){ + e.preventDefault(); + const list = Array.from(getContainer()?.querySelectorAll(scopeSelector())||[]); + selected.clear(); list.forEach(el=> selected.add(el)); updateInfo(); return; + } + // Esc + if (e.key==='Escape'){ + e.preventDefault(); + if (selected.size) clearSelection(); else enable(false); + return; + } + const arrows = ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown']; + if (arrows.includes(e.key) && selected.size){ + e.preventDefault(); + // Start an undo group on first nudge + if (!nudging){ pushHistory(); nudging = true; } + const baseStep = (snap(1) - snap(0)) || 1; + const step = e.shiftKey ? baseStep * 5 : baseStep; + let dx = 0, dy = 0; + if (e.key==='ArrowLeft') dx = -step; + if (e.key==='ArrowRight') dx = step; + if (e.key==='ArrowUp') dy = -step; + if (e.key==='ArrowDown') dy = step; + [...selected].forEach(el=>{ + ensureAbsolute(el); + const left = parseInt(el.style.left||'0',10); + const top = parseInt(el.style.top||'0',10); + if (e.altKey && e.shiftKey){ + // Resize instead of move (horizontal arrows -> width, vertical -> height) + const width = parseInt(el.style.width||`${Math.round(el.getBoundingClientRect().width)}`,10); + const height = parseInt(el.style.height||`${Math.round(el.getBoundingClientRect().height)}`,10); + if (e.key==='ArrowLeft' || e.key==='ArrowRight'){ + const nw = Math.max(16, snap(width + dx)); + el.style.width = `${nw}px`; + } else { + const nh = Math.max(16, snap(height + dy)); + el.style.height = `${nh}px`; + } + } else { + el.style.left = `${snap(left + dx)}px`; + el.style.top = `${snap(top + dy)}px`; + } + }); + updateInfo(); + } + }); + + // Finish nudge sessions on keyup to create one undo step + window.addEventListener('keyup', (e)=>{ + const arrows = ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown']; + if (nudging && arrows.includes(e.key)){ + pushHistory(); + nudging = false; + } + }); + + // Auto-enable with ?design=1 + const params = new URLSearchParams(location.search); + if (params.get('design')==='1'){ enable(true); } + updateLayoutButton(); +})(); \ No newline at end of file diff --git a/ui/fonts/MotivaSansBlack.woff.ttf b/ui/fonts/MotivaSansBlack.woff.ttf new file mode 100644 index 0000000..b77d8d5 Binary files /dev/null and b/ui/fonts/MotivaSansBlack.woff.ttf differ diff --git a/ui/fonts/MotivaSansBold.woff.ttf b/ui/fonts/MotivaSansBold.woff.ttf new file mode 100644 index 0000000..26a0b3c Binary files /dev/null and b/ui/fonts/MotivaSansBold.woff.ttf differ diff --git a/ui/fonts/MotivaSansExtraBold.ttf b/ui/fonts/MotivaSansExtraBold.ttf new file mode 100644 index 0000000..e853224 Binary files /dev/null and b/ui/fonts/MotivaSansExtraBold.ttf differ diff --git a/ui/fonts/MotivaSansLight.woff.ttf b/ui/fonts/MotivaSansLight.woff.ttf new file mode 100644 index 0000000..b2193ac Binary files /dev/null and b/ui/fonts/MotivaSansLight.woff.ttf differ diff --git a/ui/fonts/MotivaSansMedium.woff.ttf b/ui/fonts/MotivaSansMedium.woff.ttf new file mode 100644 index 0000000..5711683 Binary files /dev/null and b/ui/fonts/MotivaSansMedium.woff.ttf differ diff --git a/ui/fonts/MotivaSansRegular.woff.ttf b/ui/fonts/MotivaSansRegular.woff.ttf new file mode 100644 index 0000000..6160f8e Binary files /dev/null and b/ui/fonts/MotivaSansRegular.woff.ttf differ diff --git a/ui/fonts/MotivaSansThin.ttf b/ui/fonts/MotivaSansThin.ttf new file mode 100644 index 0000000..06aaf5a Binary files /dev/null and b/ui/fonts/MotivaSansThin.ttf differ diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..68f78af --- /dev/null +++ b/ui/index.html @@ -0,0 +1,358 @@ + + + + + Steam Analyzer + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ +
+ + + + + + + + + + + + +
+
+ +
+
+ +
+ + + + + +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ Target +
+ +
+
+
+
+ avatar +
+ + +
+ + +
+
+
+
Mode
+
+ + + +
+
+ +
+
Output
+
+
<steamid64>/<yyyymmdd_hhmmss>/
+├─ gephi/
+│  ├─ nodes.csv
+│  └─ edges.csv
+├─ estimates.json
+├─ scan.json
+└─ run.log
+
+
+
+
+ + +
+
+ +
+
+

Results

+
+
+

Run log

+
Ready.
+
+
+

Results

+
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + \ No newline at end of file diff --git a/ui/old-steam-web.css b/ui/old-steam-web.css new file mode 100644 index 0000000..fb5933e --- /dev/null +++ b/ui/old-steam-web.css @@ -0,0 +1,848 @@ +/** + * Old Steam Theme - Web Port + * A complete CSS theme inspired by the classic Steam interface + * Ready to use in any webpage + * + * Usage: + * + */ + +/* ============================================================ + COLOR VARIABLES - Customize these to match your needs + ============================================================ */ + +:root { + /* Original OGSteam Colors */ + --LightGreenBG: #5a6a50; + --GreenBG: #4c5844; + --DarkGreenBG: #3e4637; + --BorderBright: #808080; + --BorderDark: #282e22; + --OffWhite: #d8ded3; + --Maize: #c4b550; + --MaizeBG: #91863c; + --Label: #a0aa95; + --DimListText: #758666; + --DisabledText1: #75806f; + --DisabledText2: #282e22; + --FullGreen: #7ea64b; + + /* Border Styles */ + --Inset: 1px inset var(--BorderBright); + --Outset: 1px outset var(--BorderBright); + + /* Color Filters for SVG/Images */ + --MaizeFilter: brightness(0) saturate(100%) invert(75%) sepia(22%) saturate(895%) hue-rotate(15deg) brightness(90%) contrast(95%); + --OffWhiteFilter: brightness(0) saturate(100%) invert(93%) sepia(10%) saturate(157%) hue-rotate(50deg) brightness(95%) contrast(91%); + --DisabledText1Filter: brightness(0) saturate(100%) invert(52%) sepia(22%) saturate(374%) hue-rotate(49deg) brightness(93%) contrast(91%); +} + + +/* ============================================================ + BASE STYLES + ============================================================ */ + +body { + background: var(--DarkGreenBG); + color: var(--OffWhite); + font-family: Arial, Helvetica, sans-serif; + margin: 0; + padding: 0; +} + +html { + background: var(--DarkGreenBG); +} + +a { + color: var(--OffWhite); + text-decoration: none; +} + +a:hover { + color: var(--Maize); +} + +::selection { + text-shadow: none; + background-color: var(--MaizeBG); + color: var(--OffWhite); +} + + +/* ============================================================ + BUTTONS + ============================================================ */ + +button, .button, input[type="button"], input[type="submit"] { + box-shadow: none; + background: var(--GreenBG); + border-radius: 0; + border: var(--Outset); + color: var(--OffWhite); + transition: none; + padding: 5px 15px; + cursor: pointer; + font-family: inherit; +} + +button:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover { + color: var(--Maize); + background: var(--GreenBG); +} + +button:active, .button:active, input[type="button"]:active, input[type="submit"]:active { + border: var(--Inset); + background: var(--DarkGreenBG); +} + +button:disabled, button.disabled, .button:disabled, .button.disabled { + color: var(--DisabledText1); + background-color: var(--DarkGreenBG); + cursor: not-allowed; +} + +button:disabled:hover, button.disabled:hover, .button:disabled:hover, .button.disabled:hover { + color: var(--DisabledText1); +} + + +/* ============================================================ + FORM INPUTS + ============================================================ */ + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="search"], +input[type="url"], +input[type="number"], +textarea, +select { + background-color: var(--DarkGreenBG); + box-shadow: none; + border-radius: 0; + border: var(--Inset); + color: var(--OffWhite); + padding: 5px 8px; + font-family: inherit; + transition: none; +} + +input[type="text"]:hover, +input[type="email"]:hover, +input[type="password"]:hover, +input[type="search"]:hover, +input[type="url"]:hover, +input[type="number"]:hover, +textarea:hover, +select:hover { + background-color: var(--DarkGreenBG); +} + +input[type="text"]:focus, +input[type="email"]:focus, +input[type="password"]:focus, +input[type="search"]:focus, +input[type="url"]:focus, +input[type="number"]:focus, +textarea:focus, +select:focus { + background-color: var(--DarkGreenBG); + outline: 2px solid var(--Maize); + outline-offset: 0; +} + +input::placeholder, textarea::placeholder { + color: var(--Label); + opacity: 1; +} + +/* Dropdown Selects */ +select { + background: var(--DarkGreenBG); + border: var(--Inset); + border-radius: 0; + color: var(--OffWhite); + padding: 5px; + cursor: pointer; +} + +select:hover { + background: var(--GreenBG); + color: var(--Maize); +} + +/* Checkboxes */ +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 21px; + height: 21px; + background: transparent; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV/TSkUqBe0g4pChOtlFRRxLFYtgobQVWnUwufQLmjQkKS6OgmvBwY/FqoOLs64OroIg+AHi7OCk6CIl/i8ptIjx4Lgf7+497t4BQqvGVDMQB1TNMjLJhJgvrIrBV/gRQBgihiRm6qnsYg6e4+sePr7exXiW97k/x6BSNBngE4njTDcs4g3i2U1L57xPHGEVSSE+J5406ILEj1yXXX7jXHZY4JkRI5eZJ44Qi+UelnuYVQyVeIY4qqga5Qt5lxXOW5zVWoN17slfGCpqK1mu0xxDEktIIU0dyWigihosxGjVSDGRof2Eh3/U8afJJZOrCkaOBdShQnL84H/wu1uzND3lJoUSQN+LbX+MA8FdoN207e9j226fAP5n4Err+ustYO6T9GZXix4B4W3g4rqryXvA5Q4w8qRLhuRIfppCqQS8n9E3FYDhW2Bgze2ts4/TByBHXS3fAAeHwESZstc93t3f29u/Zzr9/QBxkHKmIgHJ5AAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+gDBA8nIBT2C1gAAABaSURBVDjLY2AY0YARmVOQEZ/JwMAwjQxzsibMWDgdxmFBk5yGroAQQHLIdFwK/pPjXXR9TLQI01FDRw0dNRQtLzOQmPdRAHqBksXAwDCtICOe1JIqi2EUwAAAwMkWMIiZzjgAAAAASUVORK5CYII=); + background-size: contain; + background-repeat: no-repeat; + border: none; + cursor: pointer; + margin: 0; +} + +input[type="checkbox"]:checked { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV/TSkUqBe0g4pChOtlFRRxLFYtgobQVWnUwufQLmjQkKS6OgmvBwY/FqoOLs64OroIg+AHi7OCk6CIl/i8ptIjx4Lgf7+497t4BQqvGVDMQB1TNMjLJhJgvrIrBV/gRQBgihiRm6qnsYg6e4+sePr7exXiW97k/x6BSNBngE4njTDcs4g3i2U1L57xPHGEVSSE+J5406ILEj1yXXX7jXHZY4JkRI5eZJ44Qi+UelnuYVQyVeIY4qqga5Qt5lxXOW5zVWoN17slfGCpqK1mu0xxDEktIIU0dyWigihosxGjVSDGRof2Eh3/U8afJJZOrCkaOBdShQnL84H/wu1uzND3lJoUSQN+LbX+MA8FdoN207e9j226fAP5n4Err+ustYO6T9GZXix4B4W3g4rqryXvA5Q4w8qRLhuRIfppCqQS8n9E3FYDhW2Bgze2ts4/TByBHXS3fAAeHwESZstc93t3f29u/Zzr9/QBxkHKmIgHJ5AAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+gDBA8pLxrKO0cAAAEuSURBVDjL7ZSxdYMwEIaFpPdAomIFVsAjwApu3Tqu3GWGdKlw2rReAUYwK7ACFZJ4TyelQXmEZ2FMyuS6k07f3f2SDqE/bcHUOR8PLwihcgPn9P7xeXEOnW2W84BHNink4guwW9qdn8O/0W4Yhtd765ugxpik7/tKa52vhmqtc6115gNKKSsAyH1JvZVKKas5GABSIcQNALKlTrxQa22ilLoCQOqAUsrKGJO6mDAM357W1BiTKqWuWutcCHFzwCAIOs55QSmtV0MppTUhpBkrzIQQlbU2cUDGmBe4WCljrHDg72CMW875jlLabNIUY9xFUbTHGLcOOCZqHz25RU0JIW0URXtCSM05360B3oWOf3mqbxPHcYEx7hb+/s87mU8bhFB5Ph6enVQn9G/OvgCMcoYwTsJ1lAAAAABJRU5ErkJggg==); +} + +/* Radio Buttons */ +input[type="radio"] { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + margin: 0; + height: 13px; + width: 13px; + border: var(--Inset); + background: transparent; + position: relative; + cursor: pointer; +} + +input[type="radio"]::before { + content: ""; + height: 7px; + width: 7px; + margin: 2px 0 0 2px; + display: block; + opacity: 0; + background-color: var(--Maize); + position: absolute; +} + +input[type="radio"]:checked::before { + opacity: 1; +} + +input[type="radio"]:focus, +input[type="checkbox"]:focus { + outline: 2px solid var(--Maize); + outline-offset: 2px; +} + +/* Labels */ +label { + color: var(--Label); + cursor: pointer; +} + +label.active, +label:hover { + color: var(--Maize); +} + + +/* ============================================================ + CONTAINERS & PANELS + ============================================================ */ + +.panel, .container, .card, .box { + border: var(--Outset); + padding: 15px; + background-color: var(--GreenBG); + margin-bottom: 15px; + box-sizing: border-box; +} + +.panel-inset, .container-inset { + border: var(--Inset); + background-color: var(--DarkGreenBG); +} + +.panel-highlight, .container-highlight { + background-color: var(--LightGreenBG); + border: var(--Outset); +} + + +/* ============================================================ + NAVIGATION & MENUS + ============================================================ */ + +nav, .navbar { + background: var(--GreenBG); + border: var(--Outset); + padding: 0; + margin: 0; +} + +nav ul, .navbar ul, .menu { + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: 2px; +} + +nav li, .navbar li, .menu li { + margin: 0; + padding: 0; +} + +nav a, .navbar a, .menu a, .nav-link { + display: block; + padding: 8px 15px; + background: var(--GreenBG); + border: var(--Outset); + color: var(--OffWhite); + text-decoration: none; + transition: none; + box-sizing: border-box; +} + +nav a:hover, .navbar a:hover, .menu a:hover, .nav-link:hover { + background: var(--GreenBG); + color: var(--Maize); +} + +nav a:active, .navbar a:active, .menu a:active, .nav-link:active { + border: var(--Inset); + background: var(--DarkGreenBG); +} + +nav a.active, .navbar a.active, .menu a.active, .nav-link.active { + background: var(--DarkGreenBG); + border: var(--Inset); + color: var(--Maize); +} + +/* Dropdown Menus */ +.dropdown { + position: relative; +} + +.dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + background: var(--GreenBG); + border: var(--Outset); + box-shadow: none; + z-index: 1000; + min-width: 150px; + padding: 0; + margin: 4px 0 0 0; +} + +.dropdown:hover .dropdown-menu, +.dropdown.active .dropdown-menu { + display: block; +} + +.dropdown-menu li { + display: block; +} + +.dropdown-menu a { + border: none; + padding: 8px 15px; +} + +.dropdown-menu a:hover { + background: transparent; + color: var(--Maize); +} + + +/* ============================================================ + TABLES + ============================================================ */ + +table { + border-collapse: collapse; + width: 100%; + background: var(--GreenBG); + border: var(--Outset); +} + +thead { + background: var(--LightGreenBG); +} + +th { + text-align: left; + padding: 8px 10px; + color: var(--Maize); + font-weight: bold; + border-bottom: 1px solid var(--BorderBright); +} + +td { + padding: 8px 10px; + color: var(--OffWhite); + border-bottom: 1px solid var(--BorderDark); +} + +tr:hover { + background: var(--LightGreenBG); +} + +tr:last-child td { + border-bottom: none; +} + + +/* ============================================================ + MODALS & DIALOGS + ============================================================ */ + +.modal, .dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--GreenBG); + border: var(--Outset); + box-shadow: none; + padding: 20px; + z-index: 9999; + max-width: 90%; + max-height: 90vh; + overflow: auto; +} + +.modal-overlay, .dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 9998; +} + +.modal-header, .dialog-header { + color: var(--Maize); + font-size: 18px; + font-weight: bold; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--BorderBright); +} + +.modal-body, .dialog-body { + color: var(--OffWhite); + margin-bottom: 15px; +} + +.modal-footer, .dialog-footer { + display: flex; + gap: 10px; + justify-content: flex-end; + padding-top: 10px; + border-top: 1px solid var(--BorderBright); +} + +/* Close Button */ +.close-button, .modal-close, .dialog-close { + background: var(--GreenBG); + border: var(--Outset); + border-radius: 0; + height: 20px; + width: 20px; + padding: 0; + cursor: pointer; + transition: none; + display: flex; + align-items: center; + justify-content: center; + color: var(--OffWhite); + font-size: 16px; + line-height: 1; +} + +.close-button:hover, .modal-close:hover, .dialog-close:hover { + background: var(--GreenBG); + color: var(--Maize); +} + +.close-button:active, .modal-close:active, .dialog-close:active { + background: var(--DarkGreenBG); + border: var(--Inset); +} + + +/* ============================================================ + SCROLLBARS + ============================================================ */ + +::-webkit-scrollbar { + width: 14px; + height: 14px; +} + +::-webkit-scrollbar-track { + background: var(--DarkGreenBG); +} + +::-webkit-scrollbar-thumb { + background-color: var(--GreenBG); + border-radius: 0; + border: var(--Outset); +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--LightGreenBG); +} + +::-webkit-scrollbar-corner { + background: var(--DarkGreenBG); +} + + +/* ============================================================ + SEARCH BOXES + ============================================================ */ + +.search-box, .searchbox { + display: flex; + align-items: center; + background: var(--DarkGreenBG); + border: var(--Inset); + border-radius: 0; + padding: 5px; +} + +.search-box input, .searchbox input { + background: transparent; + border: none; + color: var(--Label); + padding: 0 5px; + flex: 1; +} + +.search-box input:focus, .searchbox input:focus { + outline: none; +} + +.search-button { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + width: 20px; + height: 20px; +} + + +/* ============================================================ + TAGS & BADGES + ============================================================ */ + +.tag, .badge { + display: inline-block; + padding: 3px 8px; + background: var(--GreenBG); + border: var(--Outset); + color: var(--OffWhite); + font-size: 12px; + margin: 2px; +} + +.tag:hover, .badge:hover { + color: var(--Maize); +} + + +/* ============================================================ + ALERTS & NOTIFICATIONS + ============================================================ */ + +.alert, .notification { + padding: 12px 15px; + margin: 10px 0; + border: var(--Outset); + background: var(--GreenBG); + color: var(--OffWhite); +} + +.alert-info, .notification-info { + background: var(--LightGreenBG); + color: var(--OffWhite); +} + +.alert-success, .notification-success { + background: var(--FullGreen); + color: var(--OffWhite); +} + +.alert-warning, .notification-warning { + background: var(--MaizeBG); + color: var(--DarkGreenBG); +} + +.alert-error, .notification-error { + background: var(--DarkGreenBG); + border-color: #8b0000; + color: #ff6b6b; +} + + +/* ============================================================ + HEADINGS + ============================================================ */ + +h1, h2, h3, h4, h5, h6 { + color: var(--OffWhite); + margin: 0 0 10px 0; + font-weight: bold; +} + +h1 { + font-size: 28px; + color: var(--Maize); +} + +h2 { + font-size: 24px; +} + +h3 { + font-size: 20px; +} + +h4 { + font-size: 18px; +} + +h5, h6 { + font-size: 16px; +} + + +/* ============================================================ + LISTS + ============================================================ */ + +ul, ol { + color: var(--OffWhite); +} + +ul li::marker { + color: var(--Maize); +} + +ol li::marker { + color: var(--Maize); +} + + +/* ============================================================ + CODE & PRE + ============================================================ */ + +code { + background: var(--DarkGreenBG); + padding: 2px 6px; + border: var(--Inset); + color: var(--Maize); + font-family: 'Courier New', monospace; +} + +pre { + background: var(--DarkGreenBG); + border: var(--Inset); + padding: 10px; + overflow-x: auto; + color: var(--OffWhite); +} + +pre code { + background: transparent; + border: none; + padding: 0; +} + + +/* ============================================================ + HORIZONTAL RULE + ============================================================ */ + +hr { + border: none; + border-top: 1px solid var(--BorderBright); + margin: 15px 0; +} + + +/* ============================================================ + PROGRESS BARS + ============================================================ */ + +progress { + appearance: none; + -webkit-appearance: none; + width: 100%; + height: 20px; + border: var(--Inset); + background: var(--DarkGreenBG); +} + +progress::-webkit-progress-bar { + background: var(--DarkGreenBG); +} + +progress::-webkit-progress-value { + background: var(--FullGreen); +} + +progress::-moz-progress-bar { + background: var(--FullGreen); +} + + +/* ============================================================ + TOOLTIPS + ============================================================ */ + +[data-tooltip] { + position: relative; + cursor: help; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 5px 10px; + background: var(--GreenBG); + border: var(--Outset); + color: var(--OffWhite); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + margin-bottom: 5px; + z-index: 1000; +} + +[data-tooltip]:hover::after { + opacity: 1; +} + + +/* ============================================================ + UTILITY CLASSES + ============================================================ */ + +.text-muted { + color: var(--Label); +} + +.text-disabled { + color: var(--DisabledText1); +} + +.text-highlight { + color: var(--Maize); +} + +.bg-dark { + background: var(--DarkGreenBG); +} + +.bg-normal { + background: var(--GreenBG); +} + +.bg-light { + background: var(--LightGreenBG); +} + +.border-outset { + border: var(--Outset); +} + +.border-inset { + border: var(--Inset); +} + +.no-border { + border: none; +} + +.full-width { + width: 100%; +} + +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.flex { + display: flex; +} + +.flex-column { + flex-direction: column; +} + +.flex-center { + justify-content: center; + align-items: center; +} + +.gap-small { + gap: 5px; +} + +.gap-medium { + gap: 10px; +} + +.gap-large { + gap: 20px; +} + + +/* ============================================================ + RESPONSIVE UTILITIES + ============================================================ */ + +@media (max-width: 768px) { + nav ul, .navbar ul { + flex-direction: column; + } + + .modal, .dialog { + width: 95%; + max-width: none; + } +} + + +/* ============================================================ + PRINT STYLES + ============================================================ */ + +@media print { + body { + background: white; + color: black; + } + + .modal-overlay, .dialog-overlay, + nav, .navbar, + button, .button { + display: none; + } +} \ No newline at end of file diff --git a/ui/theme-override.css b/ui/theme-override.css new file mode 100644 index 0000000..2d6f00f --- /dev/null +++ b/ui/theme-override.css @@ -0,0 +1,535 @@ +/* MotivaSans font loading - matching your actual files */ +@font-face { + font-family: "MotivaSans"; + src: url("./fonts/MotivaSansRegular.woff.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "MotivaSans"; + src: url("./fonts/MotivaSansMedium.woff.ttf") format("truetype"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "MotivaSans"; + src: url("./fonts/MotivaSansBold.woff.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* Apply font globally */ +body, +button, +input, +select, +textarea { + font-family: "MotivaSans", Arial, Helvetica, sans-serif; +} + +/* Emphasize headings and key UI text */ +h1,h2,h3,.section-title,.target-label,.btn-mode,.btn-depth,.nav-link{ + font-weight: 700; +} +/* Stronger heading font enforcement */ +.panel h1,.panel h2,.panel h3,.panel .section-title,.panel .target-label,.tabs .nav-link,strong,b{ + font-family: "MotivaSans", Arial, Helvetica, sans-serif !important; + font-weight: 700 !important; +} + +/* Base layout */ +html, body { + height: 100%; +} +body { + margin: 0; + padding: 0; + overflow: hidden; /* prevent page scrollbars */ + /* Default: do not allow dragging anywhere except explicit regions */ + -webkit-app-region: no-drag; +} + +/* Strict drag policy: everything is non-draggable by default; only the toolbar background can drag */ +html, body, * { -webkit-app-region: no-drag; } +.toolbar { -webkit-app-region: drag !important; } +.toolbar * { -webkit-app-region: no-drag !important; } + +/* Hide native scrollbars globally (Windows/WebKit/Firefox) */ +* { scrollbar-width: none; -ms-overflow-style: none; } +*::-webkit-scrollbar { width: 0; height: 0; } + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + padding: 6px 10px; + gap: 12px; + background: linear-gradient(to bottom, rgba(255,255,255,0.06), rgba(0,0,0,0.06)), var(--GreenBG); + border-bottom: var(--Outset); + box-shadow: inset 0 1px rgba(255,255,255,0.05), inset 0 -1px rgba(0,0,0,0.25); + -webkit-app-region: drag; /* allow moving window by dragging the top bar */ +} + +.toolbar .tabs { + flex: 0 0 auto; + margin: 0; + border: none; +} + +.toolbar .tabs ul { + gap: 12px; /* spacing between Scan and Results */ + margin: 0; +} +.toolbar .button, .toolbar .align-icon, .toolbar .icon-btn, .toolbar .win-btn { height:26px; line-height:24px; } + +/* Restrict draggable region to the top toolbar only. + All interactive elements within the toolbar must be marked as no-drag + to keep clicks and text selection working normally. */ +.toolbar .button, +.toolbar .align-icon, +.toolbar .icon-btn, +.toolbar .win-btn, +.toolbar .tabs, +.toolbar .tabs ul, +.toolbar .nav-link, +#btn-key, +.window-controls { + -webkit-app-region: no-drag; +} + +.toolbar-actions { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; +} +.toolbar .button{ height:26px; padding:0 8px; display:flex; align-items:center; } +.toolbar .align-icon{ height:26px; padding:0 8px; } +.toolbar .icon-btn{ height:26px; padding:0 6px; } +.toolbar .win-btn{ height:26px; line-height:24px; } +#btn-key{ width:26px; min-width:26px; padding:0; display:flex; align-items:center; justify-content:center; } +#btn-key .icon-img{ width:16px; height:16px; } +.api-key-hint{ color: var(--Maize); font-size: 12px; margin-right: 6px; align-self:center; } + +/* Small icon imgs used inside buttons */ +.icon-img{ width:18px; height:18px; object-fit:contain; filter: var(--OffWhiteFilter); } +.button:hover .icon-img{ filter: var(--MaizeFilter); } + +.align-icon{ display:flex; align-items:center; gap:6px; height:26px; padding: 0 8px; } +.window-controls{ display:flex; gap:6px; margin-left:6px; } +.win-btn{ width:28px; height:26px; padding:0; text-align:center; line-height:24px; } + +.icon-btn { + min-width: 28px; + height: 26px; /* match window controls height */ + padding: 0 6px; +} + +/* Global button sizing: size-to-content */ +.button{ + width: auto; + min-width: 0; + height: 28px; + padding: 0 10px; +} + +/* Views */ +.views { + padding: 12px; + position: relative; /* allow internal overlays to align to content area */ + overflow: hidden; /* contain content without window scrollbars */ +} + +/* Lock overlay - blocks interaction until API key present */ +.lock-overlay{ + position: absolute; /* sit inside .views only */ + inset: 0; /* cover the content area exactly */ + z-index: 3000; + background: rgba(0,0,0,0.05); + cursor: not-allowed; +} +/* overlay has no embedded hint; hint is in toolbar */ +/* Design mode: never allow the lock/modal layers to block input */ +body.design-mode #lock-overlay{ display: none !important; } +body.design-mode .vap-modal{ display: none !important; } + +/* Modal */ +.vap-modal{ position: fixed; left:0; top:0; right:0; bottom:0; z-index: 4000; background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center; } +.vap-modal[hidden]{ display:none; } +.vap-modal .modal-content{ padding: 16px 18px; min-width: 340px; display:flex; flex-direction:column; gap:10px; color: var(--OffWhite); } +.vap-modal .note-text{ color: var(--OffWhite); } +.form-error{ color: #d04a4a; font-size: 12px; } +.vap-modal .modal-actions{ display:flex; gap:8px; justify-content:flex-end; } + +/* Toast */ +.toast{ position: fixed; bottom: 20px; right: 20px; z-index: 4100; background: var(--GreenBG); border: var(--Outset); color: var(--OffWhite); padding: 6px 10px; font-size: 12px; } +.toast[hidden]{ display:none; } + +.view { + display: none; +} + +.view.active { + display: block; +} + +/* Main scan grid layout */ +body:not(.layout-abs) .scan-grid { + display: grid; + grid-template-columns: 1fr 48px 1.1fr; /* params | spacer(rail) | target */ + grid-template-rows: auto 1fr; + gap: 16px; + max-width: 1440px; + margin: 0 auto; /* center the main content */ +} + +/* Parameters panel */ +.params-panel { + grid-column: 1; + grid-row: 1; + padding: 14px 16px; +} + +.param-row { + display: grid; + grid-template-columns: 100px 1fr; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.param-row:last-child { + margin-bottom: 0; +} + +.depth-row { + grid-template-columns: 110px auto 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.param-label { + color: var(--OffWhite); + font-size: 13px; + text-align: left; +} + +.param-input { + max-width: 280px; +} + +/* Depth buttons */ +.depth-buttons { + display: flex; + gap: 4px; +} + +.btn-depth { + min-width: 30px; + height: 26px; + padding: 3px 8px; + font-size: 13px; + background: var(--GreenBG); + border: var(--Outset); + color: var(--OffWhite); + cursor: pointer; + font-weight: 500; +} + +.btn-depth:hover { + color: var(--Maize); +} + +.btn-depth.active { + background: var(--DarkGreenBG); + border: var(--Inset); + color: var(--Maize); +} + +/* Checkboxes */ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.check-item { + display: flex; + align-items: center; + gap: 8px; + color: var(--OffWhite); + font-size: 13px; + cursor: pointer; +} + +.check-item input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.check-item:hover { + color: var(--Maize); +} + +/* Apply button row */ +.apply-row { + display: flex; + justify-content: flex-end; + grid-column: 1 / -1; + margin-top: 8px; +} +.apply-row .button{ padding: 6px 12px; } + +/* Rail: vertical spacer with icons and recent targets stacked */ +.recent-thumbs{ display:flex; flex-direction:column; gap:8px; } +.recent-thumb{ width:44px; height:44px; border: var(--Outset); background: var(--DarkGreenBG); display:flex; align-items:center; justify-content:center; cursor:pointer; overflow:hidden; } +.recent-thumb img{ width:100%; height:100%; object-fit:cover; display:block; } +.recent-thumb:hover{ filter: brightness(1.1); } + +/* Spacer column */ +.spacer-column{ + grid-column: 2; + grid-row: 1 / span 2; + position: relative; + background: rgba(255,255,255,0.06); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: var(--Outset); + display:flex; + align-items:center; + justify-content:center; +} +.spacer-column::before{ + content:""; + position:absolute; + left:50%; + top:6px; + bottom:6px; + width:2px; + background: rgba(0,0,0,0.35); + filter: brightness(0.8); +} +.rail-icons{ position:relative; z-index:1; display:flex; flex-direction:column; align-items:center; gap:10px; padding:8px 0; } +.rail-btn{ width:32px; height:32px; display:flex; align-items:center; justify-content:center; border: var(--Outset); background: var(--GreenBG); cursor:pointer; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.3); } +.rail-btn:hover{ filter: brightness(1.12); } + +/* Target panel */ +.target-panel { + grid-column: 3; + grid-row: 1 / span 2; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; + width: 320px; + max-width: 320px; + justify-self: start; + border-bottom: none !important; +} + +.target-top { display:grid; grid-template-columns: 64px 1fr; align-items:center; gap:8px; } +.target-input-wrap{ position: relative; width: 240px; display:inline-block; margin-left: -6px; } +.target-input{ height: 26px; width: 100%; } + +.target-label { + color: var(--OffWhite); + font-size: 13px; +} + +/* checkmark removed; autodetect is used */ + +.target-username { + font-size: 22px; + font-weight: 700; + color: var(--OffWhite); +} + +.target-username{ font-size: 22px; font-weight:700; color: var(--OffWhite); min-height: 26px; text-align:center; } +.target-avatar { background: #2a2e2a; border: var(--Inset); width: 280px; height: 280px; display:block; margin: 0 auto; } +.avatar-img{ width:100%; height:100%; object-fit:cover; display:block; } + +.btn-analyze { padding: 10px 16px; font-size: 15px; width: 240px; text-align:center; align-self: center; } + +.btn-estimate { padding: 8px 14px; font-size: 14px; align-self:center; width: 200px; text-align:center; } + +/* Mode + Output row (for flow layout only) */ +body:not(.layout-abs) .mode-output-row { + grid-column: 1; + grid-row: 2; + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +body:not(.layout-abs) .mode-output-wrap { + display:grid; + grid-template-columns:1fr; + grid-template-rows:auto 1fr; + gap:12px; + padding:16px 18px; + align-items: start; +} +body:not(.layout-abs) .mode-panel { + padding-right: 0; + display:grid; + grid-template-columns: repeat(3, 1fr); + gap:0; +} + +.section-title { + color: var(--OffWhite); + font-size: 13px; + font-weight: 700; + margin-bottom: 8px; +} + +.btn-mode { + padding: 8px 10px; + background: var(--GreenBG); + border: var(--Outset); + color: var(--OffWhite); + cursor: pointer; + font-size: 13px; + text-align: center; +} +.btn-mode + .btn-mode { margin-left: -1px; } +.btn-mode:first-child { border-top-left-radius: 6px; border-bottom-left-radius: 6px; } +.btn-mode:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; } + +.btn-mode:hover { + color: var(--Maize); +} + +.btn-mode.active { + background: var(--DarkGreenBG); + border: var(--Inset); + color: var(--Maize); +} + +/* Output panel */ +.output-panel { padding-left: 0; } + +.output-tree { + padding: 12px 14px; + background: var(--DarkGreenBG); + border: var(--Inset); + overflow: hidden; /* avoid inner scrollbars */ +} + +.output-tree pre { + margin: 0; + font-family: "Courier New", Courier, monospace; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +/* All spans inside the tree default to white */ +#output-tree span { + color: var(--OffWhite); +} +/* Active elements become yellow */ +.output-tree .tree-active { + color: var(--Maize) !important; +} + +/* retain icon-btn for toolbar-only compact icons */ + +/* Results view */ +.results-panel { + padding: 20px; +} + +.results-panel h2 { + color: var(--Maize); + margin-bottom: 16px; +} + +.results-layout { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.results-log, +.results-list { + padding: 14px; + min-height: 260px; +} + +.results-log pre { + margin: 0; + font-size: 12px; + color: var(--OffWhite); + white-space: pre-wrap; + overflow: hidden; +} + +.results-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.results-list li { + padding: 8px 10px; + border: var(--Outset); + background: var(--GreenBG); + margin-bottom: 6px; + cursor: pointer; + color: var(--OffWhite); +} + +.results-list li:hover { + color: var(--Maize); +} + +.results-actions { + text-align: right; +} + +/* Responsive */ +@media (max-width: 1100px) { + .scan-grid { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto; + } + + .help-column, + .icon-column { + flex-direction: row; + grid-column: 1; + } + + .target-panel { + grid-column: 1; + grid-row: auto; + } + + .mode-output-row { + grid-template-columns: 1fr; + } + + .results-layout { + grid-template-columns: 1fr; + } +} + +/* Ensure coordinate mode layout works without interference */ +body.layout-abs #box-output .output-panel { + padding: 0; + display: flex; + flex-direction: column; + align-items: center; +} +/* Ensure preview wrapper doesn't break centering in coordinate mode */ +body.layout-abs #box-output .output-panel .panel-inset { + width: 100%; +} \ No newline at end of file diff --git a/vapora.jpg b/vapora.jpg new file mode 100644 index 0000000..e77800f Binary files /dev/null and b/vapora.jpg differ diff --git a/vapora/config_default.yaml b/vapora/config_default.yaml index e8d94cc..ee13556 100644 --- a/vapora/config_default.yaml +++ b/vapora/config_default.yaml @@ -8,12 +8,21 @@ skip_private_profiles: true include_group_links: true include_game_overlap: false -# enrichment +# enrichment (for Graphi) hub_percentile: 0.99 # top 1% betweenness → is_hub -# probable friends weights -weights: -mutual: 1.0 -jaccard: 1.0 -groups: 0.5 -games: 0.5 +# run mode: full | basic | graphi +run_mode: full + +# IRL friend probability options (for estimates) +irl: + top_n_for_mean: 5 + reasonable_count: 50 + +# Location probability options (for estimates) +location: + reasonable_count: 100 + score_strategy: multiply # multiply | sum + +# steamhistory +request_timeout: 25 \ No newline at end of file diff --git a/vapora/enricher.py b/vapora/enricher.py index 42bbdee..7e86613 100644 --- a/vapora/enricher.py +++ b/vapora/enricher.py @@ -6,7 +6,7 @@ import networkx as nx from community import community_louvain -from .utils import ensure_dir, write_json +from .utils import ensure_dir def _clean_edges(nodes: Dict[str, Dict], edges: List[Dict]) -> List[Dict]: @@ -49,8 +49,11 @@ def export_gephi( state: Dict, out_dir: Path, hub_percentile: float = 0.99, -) -> Tuple[Path, Path, Path]: - """Compute metrics and export CSVs; returns paths.""" +) -> Tuple[Path, Path]: + """ + Compute metrics and export CSVs under 'graphi' folder. + Returns (nodes_csv, edges_csv). scan.json is not written here. + """ nodes = state["nodes"] edges = _clean_edges(nodes, state["edges"]) G = _build_graph(nodes, edges) @@ -61,16 +64,14 @@ def export_gephi( bet = nx.betweenness_centrality(G) mod = community_louvain.best_partition(G) - # hub flag + # hub flag threshold if bet: - thresh = sorted(bet.values(), reverse=True)[ - max(0, int(len(bet) * hub_percentile) - 1) - ] + idx = max(0, int(len(bet) * hub_percentile) - 1) + thresh = sorted(bet.values(), reverse=True)[idx] else: thresh = 1.0 - # export - gephi_dir = out_dir / "gephi" + gephi_dir = out_dir / "graphi" ensure_dir(gephi_dir) nodes_csv = gephi_dir / "nodes.csv" edges_csv = gephi_dir / "edges.csv" @@ -102,11 +103,7 @@ def export_gephi( for e in edges: f.write(f"{e['a']},{e['b']},{e.get('type','friend')}\n") - # save raw - raw_json = out_dir / "scan.json" - write_json(raw_json, state) - - return nodes_csv, edges_csv, raw_json + return nodes_csv, edges_csv def _esc(s: str) -> str: @@ -116,4 +113,4 @@ def _esc(s: str) -> str: .replace("\r", " ") .replace('"', "'") .strip() - ) + ) \ No newline at end of file diff --git a/vapora/ids.py b/vapora/ids.py new file mode 100644 index 0000000..bc79271 --- /dev/null +++ b/vapora/ids.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import re +from typing import Optional, Tuple +from urllib.parse import urlparse + +from .steam_api import SteamAPI + +OFFSET = 76561197960265728 + + +def _from_steam2(s2: str) -> Optional[str]: + # STEAM_X:Y:Z + m = re.match(r"^STEAM_([0-5]):([01]):(\d+)$", s2, re.IGNORECASE) + if not m: + return None + universe, y, z = int(m.group(1)), int(m.group(2)), int(m.group(3)) + account_id = z * 2 + y + return str(OFFSET + account_id) + + +def _from_steam3(s3: str) -> Optional[str]: + # [U:1:ACCOUNTID] or U:1:ACCOUNTID + m = re.match(r"^\[?([a-zA-Z]):(\d+):(\d+)\]?$", s3) + if not m: + # Also accept [U:1:XXXXX:XXXX] -> take third component as account id + m2 = re.findall(r"(\d+)", s3) + if len(m2) >= 1: + account_id = int(m2[-1]) + return str(OFFSET + account_id) + return None + account_id = int(m.group(3)) + return str(OFFSET + account_id) + + +def _from_profiles_url(url: str) -> Optional[str]: + try: + p = urlparse(url) + parts = [x for x in p.path.split("/") if x] + if len(parts) >= 2 and parts[0].lower() == "profiles": + if re.fullmatch(r"\d{17}", parts[1]): + return parts[1] + except Exception: + return None + return None + + +def _vanity_from_id_url(url: str) -> Optional[str]: + try: + p = urlparse(url) + parts = [x for x in p.path.split("/") if x] + if len(parts) >= 2 and parts[0].lower() == "id": + return parts[1] + except Exception: + return None + return None + + +def parse_any_steam_input(s: str, api: SteamAPI) -> Optional[str]: + """ + Accepts: + - SteamID2 (STEAM_X:Y:Z) + - SteamID3 ([U:1:XXXXX] or U:1:XXXXX) + - SteamID64 (17 digits) + - Full profile URLs (/profiles/ or /id/) + - Vanity strings (customURL) + """ + x = (s or "").strip() + + # 64-bit + if re.fullmatch(r"\d{17}", x): + return x + + # Steam2 + sid64 = _from_steam2(x) + if sid64: + return sid64 + + # Steam3 variants + sid64 = _from_steam3(x) + if sid64: + return sid64 + + # /profiles/ + sid64 = _from_profiles_url(x) + if sid64: + return sid64 + + # /id/ → resolve + vanity = _vanity_from_id_url(x) + if vanity: + return api.ensure_steam64_from_vanity(vanity) + + # Plain vanity string (letters, digits, dash, underscore) + if re.fullmatch(r"[A-Za-z0-9_\-\.]{2,64}", x): + return api.ensure_steam64_from_vanity(x) + + # Last resort: try to take the last path part as vanity + try: + p = urlparse(x) + if p.scheme in ("http", "https"): + tail = [z for z in p.path.split("/") if z] + if tail: + return api.ensure_steam64_from_vanity(tail[-1]) + except Exception: + pass + + return None \ No newline at end of file diff --git a/vapora/irl.py b/vapora/irl.py new file mode 100644 index 0000000..282143a --- /dev/null +++ b/vapora/irl.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional, Set, Tuple + + +@dataclass +class FriendProbOpts: + top_n_for_mean: int = 5 + reasonable_count: int = 50 # constant baseline + + +@dataclass +class LocationProbOpts: + reasonable_count: int = 100 + score_strategy: str = "multiply" # "multiply" | "sum" + + +def _neighbor_map(nodes: Dict[str, Dict]) -> Dict[str, Set[str]]: + neigh: Dict[str, Set[str]] = {} + for sid, n in nodes.items(): + neigh[sid] = set(n.get("friends", []) or []) + return neigh + + +def compute_irl_friends( + state: Dict, + opts: Optional[FriendProbOpts] = None, +) -> List[Dict]: + """ + IRL probability for seed's direct friends using mutual-counts: + - mutual = number of seed's other friends who also have this candidate + - probability blend mirrors the JS/TS logic from your app + """ + opts = opts or FriendProbOpts() + seed = state["seed"] + nodes = state["nodes"] + seed_friends = list(set(nodes.get(seed, {}).get("friends", []) or [])) + neigh = _neighbor_map(nodes) + + rows: List[Tuple[str, int]] = [] + seed_set = set(seed_friends) + for cand in seed_friends: + mutual = sum(1 for fr in seed_set if cand in neigh.get(fr, set())) + rows.append((cand, mutual)) + + if not rows: + return [] + + rows.sort(key=lambda r: r[1], reverse=True) + biggest = max(1, rows[0][1]) + + top_k = rows[: max(1, min(opts.top_n_for_mean, len(rows)))] + mean_top = sum(c for _, c in top_k) / max(1, len(top_k)) + denom_mean = max(1.0, mean_top * 1.5) + + out: List[Dict] = [] + for sid, count in rows: + mean_method = min(1.0, count / denom_mean) + max_method = min(1.0, count / biggest) + const_method = min(1.0, count / float(opts.reasonable_count)) + prob = ((mean_method * 2) + (max_method * 2) + const_method) / 5.0 + prob_pct = max(0.0, min(100.0, prob * 100.0)) + + n = nodes.get(sid, {}) + out.append( + { + "steamid": sid, + "personaname": n.get("personaname"), + "profileurl": n.get("profileurl"), + "count": count, + "probability": round(prob_pct, 2), + } + ) + return out + + +def _city_key(n: Dict) -> Optional[str]: + cc = n.get("loccountrycode") + st = n.get("locstatecode") or "" + cid = n.get("loccityid") + if cc and cid is not None: + return f"{cc}/{st}/{cid}" + return None + + +def infer_possible_locations( + state: Dict, + friend_irl: List[Dict], + opts: Optional[LocationProbOpts] = None, +) -> List[Dict]: + """ + Multiply accumulation per city key for the seed's friends that expose + location fields, then probability = (2*share + 1*constant)/3, scaled. + """ + opts = opts or LocationProbOpts() + nodes = state["nodes"] + ids = [r["steamid"] for r in friend_irl] if friend_irl else [] + if not ids: + return [] + + scores: Dict[str, float] = {} + for sid in ids: + n = nodes.get(sid, {}) + key = _city_key(n) + if not key: + continue + c = max( + 0, int(next((r["count"] for r in friend_irl if r["steamid"] == sid), 0)) + ) + if opts.score_strategy == "sum": + scores[key] = scores.get(key, 0.0) + float(c) + else: + scores[key] = (scores[key] * float(c)) if key in scores else float(c) + + if not scores: + return [] + + items = sorted(scores.items(), key=lambda kv: kv[1], reverse=True) + total = sum(v for _, v in items) or 1.0 + + out: List[Dict] = [] + for key, score in items: + cc, st, cid = key.split("/") + share = score / total + const = min(1.0, score / float(opts.reasonable_count)) + prob = ((share * 2.0) + const) / 3.0 + prob_pct = max(0.0, min(100.0, prob * 100.0)) + out.append( + { + "location": { + "countryCode": cc, + "stateCode": st or None, + "cityID": int(cid) if str(cid).isdigit() else cid, + "countryName": None, + "stateName": None, + "cityName": None, + }, + "count": score, + "probability": round(prob_pct, 2), + } + ) + return out + + +def build_estimates( + state: Dict, fopts: Optional[FriendProbOpts], lopts: Optional[LocationProbOpts] +) -> Dict: + """ + Returns a single dict ready to save as estimates.json: + { + closestFriends: [...], + possibleLocations: [...] + } + """ + friends = compute_irl_friends(state, fopts) + locs = infer_possible_locations(state, friends, lopts) if friends else [] + return {"closestFriends": friends, "possibleLocations": locs} \ No newline at end of file diff --git a/vapora/scanner.py b/vapora/scanner.py index 85c9593..cb37cd8 100644 --- a/vapora/scanner.py +++ b/vapora/scanner.py @@ -21,8 +21,8 @@ def scan_network( """BFS crawl of friends; returns a state dict suitable for export.""" state = resume_state or { "seed": seed_steamid, - "nodes": {}, # steamid -> data - "edges": [], # dicts: {a,b,type} + "nodes": {}, # steamid -> data + "edges": [], # dicts: {a,b,type} "visited": [], "queue": [], "meta": {"depth": depth}, @@ -44,21 +44,17 @@ def scan_network( continue visited.add(sid) - # ensure node if sid not in nodes: nodes[sid] = {"steamid": sid} - # fetch friends friends = api.get_friend_list(sid) nodes[sid]["friends"] = friends - # enqueue next layer if d < depth: for f in friends: if f not in visited: q.append((f, d + 1)) - # friend edges for f in friends: edges.append({"a": sid, "b": f, "type": "friend"}) @@ -68,7 +64,6 @@ def scan_network( pbar.close() - # summaries/bans for all discovered nodes all_ids = list(nodes.keys()) summaries = api.get_player_summaries(all_ids) bans = api.get_player_bans(all_ids) @@ -79,6 +74,10 @@ def scan_network( nodes[sid]["personaname"] = p.get("personaname") nodes[sid]["profileurl"] = p.get("profileurl") nodes[sid]["is_public"] = True if vis == 3 else False + # location codes for location inference + nodes[sid]["loccountrycode"] = p.get("loccountrycode") + nodes[sid]["locstatecode"] = p.get("locstatecode") + nodes[sid]["loccityid"] = p.get("loccityid") b = bans.get(sid, {}) nodes[sid]["bans"] = { @@ -110,4 +109,4 @@ def scan_network( state["nodes"] = nodes state["edges"] = edges state["queue"] = list(q) - return state + return state \ No newline at end of file diff --git a/vapora/steam_api.py b/vapora/steam_api.py index 1d36679..7dbaaf9 100644 --- a/vapora/steam_api.py +++ b/vapora/steam_api.py @@ -2,7 +2,7 @@ import time from collections import deque -from typing import Dict, List, Optional, Tuple +from typing import Deque, Dict, List, Optional import requests @@ -11,7 +11,7 @@ class RateLimiter: def __init__(self, rpm: int = 60) -> None: self.window = 60.0 self.rpm = max(1, rpm) - self.calls: deque[float] = deque() + self.calls: Deque[float] = deque() def wait(self) -> None: now = time.monotonic() @@ -43,19 +43,10 @@ def _get(self, path: str, params: Dict) -> Optional[Dict]: except requests.RequestException: return None - def ensure_steam64(self, id_or_url: str) -> Optional[str]: - s = id_or_url.strip() - if s.isdigit(): - return s - # attempt to extract vanity from url - for part in s.replace("/", " ").split(): - if part and part.lower() not in {"profiles", "id", "steamcommunity.com"}: - candidate = part - else: - candidate = s - + # Vanity only (used by ids.parse) + def ensure_steam64_from_vanity(self, vanity: str) -> Optional[str]: data = self._get( - "/ISteamUser/ResolveVanityURL/v1/", {"vanityurl": candidate} + "/ISteamUser/ResolveVanityURL/v1/", {"vanityurl": vanity} ) if not data: return None @@ -66,6 +57,7 @@ def ensure_steam64(self, id_or_url: str) -> Optional[str]: return None return None + # Standard calls def get_friend_list(self, steamid: str) -> List[str]: data = self._get( "/ISteamUser/GetFriendList/v1/", @@ -112,4 +104,4 @@ def get_user_groups(self, steamid: str) -> List[str]: if not data: return [] groups = data.get("response", {}).get("groups", []) or [] - return [g.get("gid") for g in groups if g.get("gid")] + return [g.get("gid") for g in groups if g.get("gid")] \ No newline at end of file diff --git a/vapora/steamhistory.py b/vapora/steamhistory.py new file mode 100644 index 0000000..434326a --- /dev/null +++ b/vapora/steamhistory.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import requests + + +def fetch_profile_json(url_or_path: str, timeout: int = 25) -> Dict: + """ + Fetch JSON/NDJSON from URL or local file path. + If NDJSON is given, try to parse it as a single JSON object if possible. + """ + s = (url_or_path or "").strip() + if not s: + raise ValueError("Empty URL or path.") + parsed = urlparse(s) + if parsed.scheme in ("http", "https"): + r = requests.get(s, timeout=timeout) + r.raise_for_status() + txt = r.text + else: + txt = Path(s).read_text(encoding="utf-8") + + # Heuristic: NDJSON vs JSON + if txt.lstrip().startswith("{"): + return json.loads(txt) + # NDJSON cleaned elsewhere; the caller will normalize as needed. + # Here we try to collect first JSON object that looks like a profile. + # For your workflow, you typically pass the cleaned JSON. + try: + lines = [json.loads(line) for line in txt.splitlines() if line.strip()] + # Return best-effort merge if present + for obj in lines: + if isinstance(obj, dict) and ("historic" in obj or "steamID64" in obj): + return obj + return {"_raw": lines} + except Exception: + return {"_text": txt} + + +def analyze_closeness_by_duration(profile: Dict) -> List[Dict]: + """ + Duration-based closeness as fallback when only SteamHistory JSON is present. + """ + hist = (profile or {}).get("historic", {}) or {} + friends = hist.get("friends") or [] + if not friends: + return [] + + now = int(profile.get("lastChecked") or int(time.time())) + rows: List[Tuple[str, int, Optional[str]]] = [] + for f in friends: + sid = str(f.get("Friend")) + start = int(f.get("FriendDate") or 0) + end = int(f.get("UnfriendDate") or 0) or now + dur = max(0, end - start) + name = f.get("Name") + rows.append((sid, dur, name)) + + rows.sort(key=lambda r: r[1], reverse=True) + max_d = max(1, rows[0][1]) + out: List[Dict] = [] + for sid, dur, name in rows: + prob = min(1.0, dur / float(max_d)) * 100.0 + out.append( + { + "steamid": sid, + "name": name, + "duration_seconds": dur, + "probability": round(prob, 2), + } + ) + return out + + +def extract_main_info(profile: Dict) -> Dict: + """ + Extracts basic main info (name, urls, pfp history, persona history). + Works with normalized SteamHistory JSON produced by your normalizer. + """ + user = { + "steamID64": profile.get("steamID64") or profile.get("steamid64"), + "name": profile.get("name") or profile.get("personaname"), + "communityURL": profile.get("communityURL") or profile.get("profileurl"), + "avatarHash": profile.get("avatarHash"), + "creationDate": profile.get("creationDate"), + "lastUpdated": profile.get("lastUpdated"), + "vacBanned": profile.get("vacBanned"), + "communityBanned": profile.get("communityBanned"), + "gameBans": profile.get("gameBans"), + "economyBanned": profile.get("economyBanned"), + } + hist = profile.get("historic") or {} + persona = hist.get("persona") or [] + urls = hist.get("url") or [] + pfp = hist.get("pfp") or [] + comments = hist.get("comments") or [] + + return { + "user": user, + "historic": { + "persona": persona, + "url": urls, + "pfp": pfp, + "comments": comments, + "counts": { + "persona": len(persona), + "url": len(urls), + "pfp": len(pfp), + "comments": len(comments), + }, + }, + } \ No newline at end of file diff --git a/vapora/utils.py b/vapora/utils.py index f9b3e2b..6500695 100644 --- a/vapora/utils.py +++ b/vapora/utils.py @@ -6,7 +6,7 @@ import subprocess from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict def stamp() -> str: @@ -19,13 +19,11 @@ def ensure_dir(p: Path) -> None: def write_json(path: Path, data: Dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def read_json(path: Path) -> Dict[str, Any]: - with path.open("r", encoding="utf-8") as f: - return json.load(f) + return json.loads(path.read_text(encoding="utf-8")) def open_folder(path: Path) -> None: @@ -38,3 +36,23 @@ def open_folder(path: Path) -> None: subprocess.run(["xdg-open", str(path)], check=False) except Exception: pass + + +class RunLogger: + def __init__(self, path: Path) -> None: + self.path = path + ensure_dir(path.parent) + self.path.write_text("", encoding="utf-8") + + def write(self, msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S") + line = f"[{ts}] {msg}\n" + try: + with self.path.open("a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + + def close(self) -> None: + # no-op for simple file logger + return \ No newline at end of file