Files
spoopyghosty0--frameport/scripts/showcase/steps.py
T
spoopyghosty0 60d8ab021b Install walkthrough: the setup line and Allow, in the video and on the site
- Install tutorial video (docs/showcase/videos/install.yaml, re-recorded by record_video.py): Start setup, a card
  for the setup line on the Frame (with the Developer Mode route in its note), the Frame asking and Allow clicked,
  a card for what the Frame then does by itself. Showcase: the fake pairing server takes asks; hook `frame_asks`.
- Site: the Install section is a six-step walkthrough (download, Start setup, the line or Developer Mode on the
  Frame, Allow, it sets itself up, add games and play), each step with a small live scene: the download unpacks,
  Start setup shows the line, Konsole types it and finds FramePort, Allow moves on, the setup ticks off, a game
  installs. The other ways link to /setup.
2026-10-10 10:16:13 -04:00

403 lines
17 KiB
Python

"""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 _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}