"""The step language shared by the docs screenshots (docs/showcase/shots.yaml) and the demo tour (tour.yaml). A step is a one-key mapping. App steps call the app the way its own handlers do; pointer steps move a real (drawn) mouse to things found by name (see web.Session.find); hooks run the pretend Frame's scripted events. - go: monitor # a sidebar route (library, frame, files, screenshots, live, keyboard, …) - nav: "Monitor" # click that tab like a person (checked: the page really changed) - open_game: ${game} # or {package: …, advanced: true} = with Customize open - call: {fn: screenshots_view.viewer, args: [0]} # any app method (dotted path from the app) - pop_dialog: true # close the top dialog - theme: original # switch the colour theme live (what Settings → Appearance does) - hover: "Batman" # pointer: a name, {name, nth, dx, dy, exact} or [x, y] - click: "Monitor" # also right_click, double_click - drag: ["Videos", "Documents"] # press on the first target, move through the others, release - clicks: {at: [37, 42], times: 7, gap: 0.22} # quick clicks in one place - type: "arc" # keyboard; press: Escape - scroll: {dy: 600, at: "Patches"} - wait: 1.5 # seconds - wait_for: "Starting on Frame" # until something with that name shows (timeout 15 s) - settle: true # until the picture stops changing - park: true # pointer to a place that hovers nothing - hook: fake_install # or {name: fake_install, args: [${game}]}: see HOOKS `${name}` in any string is replaced from the file's `vars:`. """ from __future__ import annotations import asyncio import re import threading import time from showcase import fakes POINTER = {"hover", "move", "click", "right_click", "double_click"} ACTIONS = POINTER | {"clicks", "nav", "go", "open_game", "call", "pop_dialog", "theme", "drag", "type", "press", "scroll", "wait", "wait_for", "settle", "park", "hook"} class StepError(ValueError): pass def substitute(value, variables: dict): """${name} → variables[name] in every string of a step (a whole-string reference keeps the value's type).""" if isinstance(value, str): m = re.fullmatch(r"\$\{(\w+)\}", value) if m: if m.group(1) not in variables: raise StepError(f"unknown variable ${{{m.group(1)}}}") return variables[m.group(1)] def one(m): if m.group(1) not in variables: raise StepError(f"unknown variable ${{{m.group(1)}}}") return str(variables[m.group(1)]) return re.sub(r"\$\{(\w+)\}", one, value) if isinstance(value, list): return [substitute(v, variables) for v in value] if isinstance(value, dict): return {k: substitute(v, variables) for k, v in value.items()} return value def validate(steps, where: str = "steps") -> None: """Raises StepError for a malformed step list (checked before a render starts, and by the tests).""" if not isinstance(steps, list): raise StepError(f"{where}: a list of steps expected") for i, step in enumerate(steps): at = f"{where}[{i}]" if not isinstance(step, dict) or len(step) != 1: raise StepError(f"{at}: a step is a mapping with exactly one action, got {step!r}") (action, value), = step.items() if action not in ACTIONS: raise StepError(f"{at}: unknown action {action!r} (known: {', '.join(sorted(ACTIONS))})") if action == "hook": name = value.get("name") if isinstance(value, dict) else value if name not in HOOKS: raise StepError(f"{at}: unknown hook {name!r} (known: {', '.join(sorted(HOOKS))})") if action == "call" and not (isinstance(value, str) or isinstance(value, dict) and "fn" in value): raise StepError(f"{at}: call needs a method name or {{fn, args}}") if action == "drag" and not (isinstance(value, list) and len(value) >= 2): raise StepError(f"{at}: drag needs two or more targets") if action in ("wait",) and not isinstance(value, (int, float)): raise StepError(f"{at}: wait needs seconds") def _resolve(app, dotted: str): obj = app for part in dotted.split("."): obj = getattr(obj, part) return obj def run(session, steps: list, variables: dict | None = None, log=None) -> None: """Run the steps on a web.Session. App calls run in this thread (the app serialises its own updates).""" app = session.app for step in substitute(steps, variables or {}): (action, v), = step.items() if log: log(f" {action}: {v}") if action == "go": app.go(v) elif action == "open_game": if isinstance(v, dict): app.open_game(v["package"], advanced=bool(v.get("advanced"))) else: app.open_game(v) elif action == "call": fn, args = (v, []) if isinstance(v, str) else (v["fn"], v.get("args") or []) method = _resolve(app, fn) if asyncio.iscoroutinefunction(method): # (e.g. a FilePicker flow): the page's loop runs it app.page.run_task(method, *args) else: method(*args) elif action == "nav": _nav(session, v, log) elif action == "pop_dialog": app.page.pop_dialog() elif action == "theme": from frameport.core import library library.set_setting("ui.theme", v) app.restyle(v) elif action in POINTER: if action in ("hover", "move"): session.move(v) elif action == "click": session.click(v) elif action == "right_click": session.click(v, button="right") else: session.click(v) session.sleep(0.08) session.page.mouse.down() session.page.mouse.up() elif action == "clicks": # quick clicks in one place (each shows the click ring) session.move(v["at"]) for _ in range(int(v.get("times", 2))): session.sleep(float(v.get("gap", 0.22))) session.page.mouse.down() session.sleep(0.05) session.page.mouse.up() elif action == "drag": session.drag(v) elif action == "type": session.type(v) elif action == "press": session.press(v) elif action == "scroll": session.scroll(v.get("dy", 300), v.get("at")) elif action == "wait": session.sleep(float(v)) elif action == "wait_for": session.find(v, timeout=15) elif action == "settle": session.settle(**({} if v is True else {k: w for k, w in v.items() if k in ("timeout",)})) elif action == "park": session.park() elif action == "hook": name, args = (v, []) if isinstance(v, str) else (v["name"], v.get("args") or []) HOOKS[name](session, *args) def _nav(session, label: str, log=None) -> None: """Click a sidebar tab like a person, then make sure the page really changed: its heading shows in the content area. A click whose handler died would otherwise leave the old page in the picture for the rest of a recording (seen on CI before property writes took FLET_LOCK; kept as a safety net): then the route is opened directly and a WARN line printed.""" from frameport.ui import app as app_module from showcase.web import Box, NotFound keys = {str(lbl).casefold(): key for key, lbl, _icon in app_module.NAV} key = keys.get(label.casefold()) if key is None: raise StepError(f"nav: {label!r} is not a sidebar tab ({', '.join(sorted(keys))})") session.click(label) vw, _vh = session.viewport content = Box(260, 0, vw - 260, 160) # right of the sidebar, the page's title row for _ in range(2): try: session.find(label, within=content, timeout=2.5) return except NotFound: print(f"WARN nav {label}: the page didn't change after the click, opening it directly", flush=True) session.app.go(key) session.find(label, within=content, timeout=2.5) # ------------------------------------------------------------------ hooks: the pretend Frame's scripted events def _fake_install(session, package: str, pace: float = 1.0, wait: bool = False) -> None: """Install `package` on the pretend Frame through the real job queue (every real stage, an upload, a passing launch test). wait: until it's done.""" app = session.app app.submit(f"Install {app._title(package)} on Frame", fakes.install_job(package, pace), package, "install", to="frame") if wait: _wait_jobs(session) def _wait_jobs(session, timeout: float = 60) -> None: deadline = time.time() + timeout session.sleep(0.5) while time.time() < deadline and any(j.active for j in session.app.jobs.jobs): session.sleep(0.3) def _held_install(session, package: str, fraction: float = 0.62) -> None: """An install held mid-upload: the transit in the sidebar, the game page and Activity. It stays there until the window closes (Session.close cancels it).""" from frameport.ui.jobs import Job app, reached = session.app, threading.Event() def run(job): rep = job.reporter for stage in fakes.FAKE_INSTALL_STAGES: rep.stage(stage) time.sleep(0.3) # past the job queue's notification throttle rep.progress(fraction, f"{package}.apk", speed="36.4 MB/s · ~1 min left") reached.set() while True: rep.check_cancel() time.sleep(0.2) app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install")) reached.wait(10) def _live_stream(session) -> None: fakes.fake_live_stream(session.app) def _stop_live(session) -> None: fakes.stop_fake_live(session.app) def _monitor_details(session) -> None: fakes.open_monitor_details(session.app) def _monitor_settled(session, timeout: float = 30) -> None: """Still pictures of the Monitor: wait until the frozen stream has replayed its history (then one more second for the page to draw the last sample).""" end = time.monotonic() + timeout while time.monotonic() < end: # the newest session, settled, and still the newest a moment later session_ = fakes.FrozenMonitorSession.LATEST if session_ is None: time.sleep(0.2) elif session_.settled.wait(max(0.0, end - time.monotonic())): time.sleep(1) if fakes.FrozenMonitorSession.LATEST is session_ and session_.settled.is_set(): return def _type_tab(session) -> None: fakes.open_type_tab(session.app) def _select_files(session, first: int = 1, count: int = 2) -> None: v = session.app.files_view for e in v.entries[first:first + count]: v._toggle(e.path, True) def _disconnect(session) -> None: """The sidebar as before connecting (the tour connects in front of the camera).""" app = session.app app.target, app.frame_info, app.frame_state = None, None, "none" app._refresh_sidebar() app.refresh_view() def _connect(session, delay: float = 1.0) -> None: """Connecting… then connected to the pretend Frame (the live card starts streaming).""" app = session.app app.frame_state = "connecting" app._refresh_sidebar() session.sleep(delay) fakes.attach_fake_frame(app, session.server.monitor_session) app.refresh_view() app._refresh_sidebar() def _settings_section(session, key: str) -> None: """Settings, scrolled to one section (its index entry).""" app = session.app app.go("settings") session.sleep(1.0) # mounted first: scrolling an unmounted column does nothing app.page.run_task(app.settings_view.show_section, key) def _first_run(session, folder: str = "D:/Games", pace: float = 1.0, tools_missing: bool = True, tools_delay: float = 4.0) -> None: """FramePort's first start (the "fresh" demo profile): the welcome screen's tool download is the pretend one (fakes.tools_job; it waits `tools_delay` s so filming catches it from the start), and "Scan a folder…" scans `folder` at once instead of opening the native folder picker (a script can't drive it): the demo games appear one by one.""" import json from frameport.core.paths import user_data_dir app = session.app fakes.TOOLS["ready"] = not tools_missing app.update_tools = lambda update=False, quiet=False: fakes.tools_job(app, pace, tools_delay) entries = json.loads((user_data_dir() / "showcase-scan.json").read_text()) app.pick_folder = lambda e=None: fakes.scan_job(app, folder, entries, pace) app.welcome_started = False app.library_view = None # (a Library built already would keep the real folder picker) def _pairing_done(session, delay: float = 1.5) -> None: """The Frame ran the setup command: FramePort hears from it and connects (the pretend Frame).""" app = session.app if app.pairing is not None: app.pairing.stop() _connect(session, delay) def _frame_asks(session, digits: str = "4831") -> None: """The Frame ran the setup line: it found FramePort and asks; the Allow / Deny card shows (setup URL flow).""" from frameport.frame.pairing import Ask app = session.app ask = Ask("showcase", "steamframe", "192.168.1.42", digits) app.pairing.asks.append(ask) if callable(app.pairing.on_ask): app.pairing.on_ask(ask) def _easter_eggs(session, on: bool = True, today: str = "", installs: int | None = None) -> None: """The app's easter eggs (ui/easter.py) on or off (the demo library has them off). today: pretend this date (YYYY-MM-DD: the holiday badges); installs: the install count so far (the next install may be a milestone). The window's shell is rebuilt, so the sidebar logo picks it up.""" import os from frameport.core import library from frameport.ui import easter from frameport.ui import theme as T library.set_setting(easter.SETTING, bool(on)) if today: os.environ["FRAMEPORT_TODAY"] = today if installs is not None: before = [f"demo.installed.{i}" for i in range(int(installs))] # that many different games so far library.set_setting(easter.INSTALLS, before) library.set_setting(easter.SHOWN, [m for m in easter.MILESTONES if m <= len(before)]) session.app.restyle(T.THEME) def _fps_dip(session, fps: float = 58.0, seconds: float = 6.0) -> None: """The pretend game stutters: the stream reports `fps` for `seconds` (the Monitor's frame-rate moods).""" fakes.FakeMonitorSession.DIP = (time.time() + seconds, fps) def _perfect_pacing(session, needed: float = 6.0, seconds: float = 60.0) -> None: """The pretend game runs right on target for `seconds`, and `needed` seconds of that count as the Monitor's "full minute" (the perfect-pacing easter egg on film).""" from frameport.ui import easter easter.PERFECT_SECONDS = float(needed) fakes.FakeMonitorSession.PERFECT = time.time() + seconds def _battery(session, percent: int = 99, plugged: bool = True) -> None: """The pretend Frame's battery from now on (the card's fully-charged sparkle: 99 then 100 on the charger).""" fakes.FakeMonitorSession.BATTERY = {"percent": int(percent), "status": "Charging" if plugged else "Discharging", "plugged": bool(plugged), "draining": not plugged, "watts": 6.5 if plugged else -1.96} def _long_upload(session, package: str, seconds: float = 30.0, after: float = 2.0, every: float = 3.5) -> None: """An install whose upload runs `seconds` (progress ticking 4x a second); the transit's long-upload hop starts after `after` seconds instead of five minutes, every `every` seconds. It finishes like a real install.""" from frameport.ui import easter from frameport.ui.jobs import Job easter.HOP_AFTER, easter.HOP_EVERY = float(after), float(every) app = session.app def run(job): rep = job.reporter for stage in fakes.FAKE_INSTALL_STAGES[:-1]: rep.stage(stage) time.sleep(0.15) rep.stage("Upload data (3 files)") steps = max(1, int(seconds * 4)) for i in range(1, steps + 1): rep.check_cancel() f = i / steps left = (1 - f) * seconds * 60 rep.progress(f, f"main.1.{package}.obb", speed=f"11.2 MB/s · ~{left / 60:.0f} min left") time.sleep(0.25) rep.stage("Finalize install") time.sleep(0.6) return "done" app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install")) def _press_keys(session, text: str, gap: float = 0.28) -> None: """Keys typed on the Type on Frame tab (the pretend keyboard), one by one.""" view = session.app.keyboard_view for ch in text: view.press("Space" if ch == " " else ch.upper()) session.sleep(gap) HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install": _held_install, "live_stream": _live_stream, "stop_live": _stop_live, "monitor_details": _monitor_details, "type_tab": _type_tab, "select_files": _select_files, "disconnect": _disconnect, "connect": _connect, "settings_section": _settings_section, "first_run": _first_run, "pairing_done": _pairing_done, "frame_asks": _frame_asks, "easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing, "battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys, "monitor_settled": _monitor_settled}