Only big files (>=8 MiB) used OpenSSH; games made mostly of small files stayed on Paramiko and were slow on a fast
link. native_upload.put_tar streams the same tar batch through system ssh with the paired key and pinned host key;
on any failure the install falls back to Paramiko's put_tar and logs it, and doesn't retry OpenSSH for later batches.
The job log now names the transport for each part. upload_bench gains app-small / paramiko-small comparisons.
README is a short overview that links to the install guide; repeated
sections are said once; Share working recipe everywhere; the setup
script names the real buttons; a shorter notice, the same as the site's.
Dialogs, help texts and tooltips cut to what the user does or gets, one
name per action (Start setup, Uninstall FramePort, Update art on Frame,
Rebuild only), plain launch-test results (C.test_result), plain summaries
for every Game setting. The setup line now points at frameport.app.
The Steam Frame (or This PC) card gets a log button when the game's last launch test ran there: it opens the
saved log (last_test.log_path) in the launch log viewer. The showcase's demo library writes a short made-up log for
each passed test so the button shows in the docs pictures.
- 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.
One line for every Frame and PC, served by the project page: curl -sL spoopyghosty0.github.io/frameport/s | bash.
- bootstrap/setup.sh finds FramePort (stdlib mDNS on 5353 for _frameport-pair._tcp; else the USB cable's address and
a /24 scan of /ping), asks with /hello, shows 4 digits, waits for Allow, then runs exactly the typed line's
`curl -fsS <pc>/<code> | bash`: bootstrap.sh is unchanged.
- PairingServer (no second server): /ping, /hello (max 3 open asks), /wait (long poll; code only once allowed),
decide(); announces itself over zeroconf while running (name, port, two words for this PC's key; never the code).
- Connect page: "Start setup" shows the static line, incoming asks as Allow / Deny cards (a toast elsewhere);
"Use the setup command" keeps the address + code line; USB setup unchanged.
- The app key is created under a lock (the announcement and the page asked for it at once on a first start).
- Tests: approval/deny/limits/timeouts, the code never in the announcement, setup.sh end to end against a real server
with a stub bootstrap (allowed and denied), digits match. ui_smoke --setup-url drives the page.
Checked on the dev Frame with a stub bootstrap: found by mDNS and by the scan fallback, allowed, code handed over.
- "Meta Quest" only as a description (headline about the Steam Frame), trademark line in the footer.
- Hero cards: generated covers in the style of the app's placeholder art (hue from the title), the same app icon on
both sides of the portal, a one-line APK name, stage ticker and progress, Play ripple, sparks; titles sized so
words never break. Click the portal for the next game; the scene leans toward the pointer.
- "The app": a clickable, simplified FramePort window (Library, game page with patch switches, Steam Frame, Files,
Live view, Type on Frame, Monitor with live sparklines) and a guided tour from a fresh start: connect the Frame,
scan a folder, install, play. The showcase videos play in a lightbox.
- Compared: FramePort, FrameDrop and Valve's own tools, from their own pages (checked 2026-10-09); the same table in
the README.
- record_video.py --size WxH (e.g. 2560x1440): films at that many device pixels; postprod reads the size at call
time and draws cards at the 1920 layout scaled up. Default output unchanged.
- Screenshots tab: Copy image (a button on the card under the pointer, the right-click menu and the viewer) puts
the full-size screenshot on this PC's clipboard (core/clipboard.py: Windows/WSL PowerShell bitmap + file,
macOS osascript, Linux wl-copy/xclip; Flet's set_image in web mode)
- Clicking a holiday badge showers the window: New Year confetti, winter snow, Valentine hearts, digits of pi,
clovers, hopping eggs, bouncing pumpkins (moved like the confetti: one page update per phase)
- Perfect pacing after 2.5 minutes on target; the Perfect pacing pill and line stay until the rate slips
- Full charge: only the ring pulses (with a glow behind it), the percentage stays put
- Upload hop a little slower
- Seven logo clicks: the portal glows and rings, a cover is tossed out and falls across the window on a random arc
- Holiday badges dance on hover; new: Valentine's, Pi Day, St Patrick's, Easter weekend, April 1 (upside-down
logo until hovered), New Year's party popper
- Monitor: a minute within 0.5 fps of target turns the fps chart into the portal gradient
- Library search "frameport": the logo spins and says "That's me!"
- Type on Frame: typing "hello" makes a headset wave (keys still go to the Frame)
- The battery ring sparkles on reaching 100 % on the charger
- Uploads over five minutes: the transit cover hops now and then
- Live view tab: a blinking ON AIR sign while streaming
- Showcase hooks perfect_pacing, battery, long_upload, press_keys
- ui/easter.py: seven quick clicks on the sidebar logo send a game's cover out of its portal for a somersault over
the wordmark; the Monitor's frame rate has a mood tooltip; holiday badges on the logo (a pumpkin Oct 24-31, a
snowflake Dec 20 - Jan 2); install milestones (confetti in the portal colours and a message at the 1st, 10th and
100th different game on the Frame). Milestones show once ever: they count different games (updates and
reinstalls don't), are stored as shown before they're shown, and the first use seeds from the games already on a
Frame (no "first game" after an app update). Off with setting ui.easter_eggs; Reduce motion keeps the words only.
- C.kv puts control values on the left; C.link (no indent) for the About page's source link.
- Showcase: the demo library has the easter eggs off; steps hooks easter_eggs / fps_dip and a clicks action;
record_video.py takes a storyboard file path (demos outside docs/).
- The install tutorial (app screen and card) shows curl -fsS <your-PC-address>:8765/<one-time-code> | bash, so
nobody takes the example for their own command; the card says FramePort fills them in.
- Videos: opening the Monitor tab no longer replays two minutes of samples (its charts raced to catch up); the
charts start there and grow live, as in the app. The pretend frame rate holds ~72 fps with a short, shallow dip
now and then (it sat at 55-61 fps for seconds). Both videos re-recorded.
- record_video.py replaces record_tour.py: one storyboard per video with its output, a start state (demo or
fresh profile, Frame connected or not, start page, live or frozen stream), setup steps before filming, filmed
scenes and instruction cards (eyebrow, heading, numbered steps, a command, a note) for what happens outside
FramePort. --changed-since records the videos whose storyboard (or the recorder) changed; several videos record
in separate processes.
- videos/install.yaml: download, first start, the welcome screen's tool download, the setup command, the command
on the Frame (card), connected, scanning a folder, installing and playing a game (81 s).
- demo_home profile "fresh": FramePort's first start (empty library, welcome screen, nothing on the Frame); its
games wait for the pretend folder scan. Fakes for a first start: no Frame discovery on the real network, the setup
command shows the docs' example address, a pretend tool download, steps hooks first_run and pairing_done.
- CI records changed (or named) videos; releases attach every docs/media/frameport-<name>.mp4 as
FramePort-<name>.mp4. INSTALL.md and README link the tutorial; SHOWCASE.md documents the storyboard format.
- Cards are opened by their title in both videos (a card's middle is its Install button).
- Library: an "All games" label and a 2 px rule (blue→orange in Portal themes) between the shelf and the grid,
hidden with the shelf.
- The pretend Frame's screenshots are the games' own in-game store screenshots (never cover art), installed games
first; the running demo game is BONELAB (it has screenshots, so Take screenshot adds a real one).
- render_docs: a shot whose steps fail is retried in a fresh window and never written (a window that didn't connect
had produced an empty Library); every shot is rendered twice in fresh windows and kept only when both agree (a
misdrawn glyph, "7.θ W", slipped through once); changes from 0.04 % of the pixels count (the sidebar card
showing another game is 0.12 %, run-to-run noise under 0.007 %). --fast renders once.
- record_tour: SHOWCASE_DEBUG=1 prints each step's start time. Docs images and the tour re-rendered.
Flet packs a patch by iterating each control's _values dict; Prop.__set__ adds/removes keys there without a lock.
A property set on a background thread (the live Frame card's sample, job progress, the library loader) while the
event loop packed a patch raised "dictionary changed size during iteration" and the update was lost (seen in CI:
a sidebar click left the old page showing). serialize_flet_updates() now makes property writes take the same
re-entrant lock as patches (app.FLET_LOCK). Reproduced with Flet's own encoder: 16 failures in 4 s before, none
after; test_property_writes_wait_for_a_patch_being_packed. The showcase server and ui_smoke serialize like the app.
Tour re-recorded with the fix (clean, no fallback clicks).
.github/workflows/showcase.yml renders the docs screenshots on UI pushes to main (and the tour when its storyboard or
scripts change, or on Run workflow), keeps every render as an artifact and opens/updates the PR showcase/update
when a picture visibly changed. Releases attach the tour as FramePort-tour.mp4. docs/SHOWCASE.md explains running
it, the step language and the CI flow; README opens with the teaser (link to the full MP4). Renders refuse
placeholder art (--allow-missing-art). tests/test_showcase.py: demo library, privacy scan, image threshold,
manifests (every docs image has a shot), step validation, element picking, pointer paths, ffmpeg graphs.
scripts/demo_record.py is replaced by the tour.
One window on the demo library, driven by name like a person would (drawn pointer, eased curved paths, a ring on
each click), filmed with Chrome's screencast (1080p, constant 30 fps from the frame timestamps), then captioned
(lower thirds in the app's colours), given title/end cards and crossfaded by postprod.py (ffmpeg only). Writes
docs/media/frameport-tour.mp4 (+ poster, best quality within 12 MB) and the README teaser docs/images/tour-teaser.webp.
Installs in the tour run a scripted job through the real queue; --draft for quick storyboard checks, --names lists
the clickable names. Element finding prefers the innermost match (a list's text includes its rows').
scripts/showcase/: the fakes split out of ui_smoke (the pretend Frame now reads the demo library's state, fixed
dates, a Take screenshot fake, a frozen monitor stream for stills), web.py (the GUI in web mode driven by name
through Flutter's semantics tree, drawn cursor, eased pointer paths, settle detection, one browser per window),
steps.py (the step language shared by screenshots and the tour), demo_home.py (21 catalog games from
docs/showcase/demo-library.yaml + demo-analyses.json, art and store details fetched once and cached) and
render_docs.py (docs/showcase/shots.yaml -> docs/images, rewritten only on a visible change; --check for CI).
C.CoverButton (grid + shelf), library.card_hover/hover_motion/scrim, game page Play shows Starting on … with a spinner; glyphs.solid + frame-solid.svg for primary buttons; reduce motion skips lift/zoom/rings. ui_smoke: card hover, button hover and launch frames.
Main's microSD drives (agent v63), Linux app icons (agent v64), OBB detection, re-analysis and catalog entries,
on top of the UI refresh. Our monitor modules ({"modules": [...]}) move to agent v65 (MIN_AGENT_MODULES = 65).
Main's new UI uses the refreshed components: "Move to…" is in the game page's sectioned menu (MenuState.movable),
the .obb question and the Move dialog use C.dialog, the Frame page's drive picker C.dropdown, line-style icons.
- One Playwright context per clip (record_video), web mode with ui_smoke's fake Frame; MP4 (x264) + poster JPG via ffmpeg, blank start trimmed
- ui_smoke: install_fakes()/attach_fake_frame() factored out so the recorder reuses them (behaviour unchanged)
- driver/shooter hand-off: the driver runs a step, signals ready, then waits (240 s max) for the shooter's screenshot + mouse actions before the next step; fixed STEP_SECONDS and the compensating sleeps (library-typed/-cleared, --hover, --gestures, --links) removed
- new step library-shelf-hover (the shelf's first card hovered: its Play button); hover positions per step
- mouse actions that save their own picture (menus) skip the duplicate -mouse shot; the -mouse shot waits the paint delay (typed search showed half-faded results)
- --links keeps the 1440x900 Add games click; --gestures positions updated for the shelf, fake screenshots keep the same day layout at any hour
- C.checklist/C.Check replace status_row: Frame 'Ready to play' (Install Lepton, Install Proton, Test, Restart the Frame when kernel keys run high), Settings Tools (Install per missing tool) and This PC
- Views fade in (150 ms AnimatedSwitcher, same-route redraws swap in place); the wordmark portal pulses once on connect; Settings > Appearance 'Reduce motion' (ui.reduce_motion) turns both off
- Settings: fixed index column (Appearance first, Remove FramePort last) that scrolls to sections; ui_smoke step settings-index
- Tests: checklist items, section order, reduce-motion default
- glyphs.transit_parts: the logo's monitor, portal and headset drawn whole (same paths/strokes), per theme colours, cached by TRANSIT_REV
- ui/transit.py: pure stage-name mapper (Analyze/Patch/Sign/Upload/Install/Test, overall fraction, waiting/failed, frame/pc) + tests
- components.Transit: created once, set() updates properties; cover rides into the portal at the progress head
- sidebar activity card (compact), game hero (updated from _on_job, no re-render), Activity running tile
- ui_smoke: a pretend running install (transit-game, transit-activity)
- app owns one MonitorHub; the Monitor tab subscribes (all modules) instead of owning a MonitorSession; the hub reconnects, _poll retries an error
- sidebar Frame card: battery ring (C.gauge), name + connection line; now-playing row (art, title, playing time, fps by level, 2-min sparkline) opens the Monitor
- card subscribes to games + battery every 5 s while connected, paused during uploads; setting ui.live_frame_card in Settings > Appearance
- ui/frame_card.py pure helpers + tests; ui_smoke injects FakeMonitorSession into the hub
The Steam Frame page lists the Frame's drives (filesystem, free space, games on each; exFAT/NTFS cards say why they
can't hold games) and sets where new games go (library setting install.drive, default internal storage). Game menu
→ "Move to…" (Frame-installed games, agent v63) picks a drive in a dialog and queues a Frame job that follows the
agent's copy with progress (cancellable until the copy starts). The game page and the Frame page's installed list
show "on SD Card" / "SD Card not inserted"; a game whose card is out counts as installed, not "Not installed".
Help texts install_drive / move_game, ui_smoke --fake-frame drives + the Move dialog, docs (FRAME_RUNTIME "Where
FramePort keeps games", INSTALL, PLAYBOOK row, CLAUDE.md).
Closes#90
- Keys typed on the Library (no dialog open, search not focused) go into the search and focus it, cursor at the end; Backspace removes the last character; Esc in a non-empty search clears it
- Pure helpers library.typed_char / should_capture (tested); C.dialog_open reads Flet's page._dialogs (snack bars don't count)
- Search hint "Type to search games and tags"
- "Add a PC VR game folder…" is now "Add a PC game folder…" (it adds Windows games too)
- ui_smoke: library-typed step types "arc" after clicking the header
- ui/menus.py (no controls): menu_sections() builds a game's menu for the Library right-click and the game page's "…" (Header entries for Artwork/Recipe/Troubleshoot, destructive items last after a divider; the page leaves out what its hero and Where-it's-installed cards offer; Type on Frame left the game menu; "Run launch test on …"; Analyze again also on the page); process_menu() for Monitor (Copy first, End…/Force quit… with distinct icons, End game… last, disabled "Protected by SteamOS")
- C.menu_items: 32 px rows, small-caps headers (dropped when empty), ICON_S icons, disabled items without a handler; C.menu_button/C.check_item; menus get a BORDER_STRONG border + shadow (PopupMenuButton and theme popup_menu_theme for ContextMenus)
- Add games menu: new labels/icons, Rescan folders at the end (toolbar button kept); docs/help updated
- Icons whose ROUNDED variant is filled are OUTLINED again (Monitor nav, Sleep, file types, …); the consistency test checks an explicit line-style allow-list
- Library Tags chip: the tag icon no longer overlaps its label
- tests/test_menus.py; ui_smoke shoots the Add games and game page menus
- updates.snooze()/hidden(): the update banner's "Not now" hides a version for 24 h (update.snoozed); "Skip this version" unchanged
- Frame page: "Switch Frame…" opens a picker of remembered Frames (+ "Find another Frame…"); separate Disconnect button
- Labels: Update Steam art on Frame, Reset to suggested recipe… (asks first; pipeline.reset_recipe without the duplicated Rift branch), Uninstall from this PC…, Change program…, Share working recipe…, Force quit/End game…, ellipsis on everything that opens a dialog/picker or asks
- One Developer Mode path (Settings → System → Enable Developer Mode / Settings → Developer → Pair new host); linux_app help points to FEX
- tr() for the remaining visible strings (install status, job stages, update dialog, settings, files); this PC, recipe, program, for example, 50%, US spelling, Steam IDs, arm64
- Long callouts (live view, Type on Frame, pairing, library empty state) shortened, details in help texts
- tests/test_ui_text.py: untranslated literals, "..."/"e.g.", vocabulary, Switch Frame not wired to disconnect; snooze expiry test
- Dual themes: "Frame" (accent), a small tilted portal (rim from the accent into SECONDARY around a dark hole,
generated in the active theme's colours, also for installed themes: glyphs.portal_mark), "Port" (SECONDARY)
- Glyphs: stroke 2 (was 1.75, lighter than Material's icons); menu items tint them TEXT like Material's own icons
- ui_smoke: waits until the web server listens (a fixed 6 s failed with a second smoke run on the machine)
- Built-ins: Portal (default), Portal (OLED) (true black) and Original (the former violet; "classic" still maps to it)
- Portal now uses both of the logo's portals: orange stays the main action and selection; the new SECONDARY token
(blue) colours outlined buttons, progress bars and switch tracks; "dual" themes add the blue-to-orange sidebar
edge, the two-colour wordmark and the selected tab's fade. Original looks exactly as before
- Theme files: <data>/themes/*.json (name, base, dual, any subset of the colour tokens), checked on install (dark
background, readable text, known colours; T.ThemeError says why). Settings -> Appearance: "Install theme file...",
"Copy this theme as a file" (a template), a bin icon removes an installed theme
- docs/THEMES.md + docs/themes/example-theme.json (kept valid by a test); ui_smoke --themes includes installed themes
- Logo (a monitor that goes through a tilted portal and comes out as the Steam Frame) in the sidebar and welcome
screen; src/assets/icon.png is the bundles' app icon (flet build; PyInstaller fallback via --icon)
- 24 px line glyphs (ui/icons/glyphs, ui/glyphs.py) replace Material icons for FramePort's own concepts: Frame,
launch test, PC, live view, screenshots, Type on Frame, verified recipe, rebuild; shown as tinted images through
C.as_icon and the button/menu helpers (same size as the Material icons next to them)
- Colour themes, dark only: "portal" (default; orange accent, blue PC side, status colours chosen so warnings never
read as the accent) and "classic" (the previous violet). Settings -> Appearance theme cards switch live:
app.restyle() rebuilds the shell and drops cached views (setting ui.theme)
- Theme colours are read when building: default arguments no longer capture tokens, module-level colour maps refill
through T.on_change; tests/test_theme.py checks both
- ui_smoke.py --themes renders the picker and every theme after a live switch