Skip to content

connect: zero-click fullscreen via a same-tab iframe, sized to the measured viewport - #281

Draft
jseifeddine wants to merge 43 commits into
sol1:mainfrom
jseifeddine:feat/zero-click-fullscreen
Draft

jseifeddine wants to merge 43 commits into
sol1:mainfrom
jseifeddine:feat/zero-click-fullscreen

Conversation

@jseifeddine

Copy link
Copy Markdown
Contributor

Stacked PR 18 of 18 — builds on #280 (feat/initial-session-size). Merge in order from #264. This PR's own change is the compare on the fork: jseifeddine/rustguac@feat/initial-session-size...feat/zero-click-fullscreen. The Files tab here includes the PRs below it until they merge into main.

Closes #257 (part 2/2).

Connect fullscreens the current tab on the click and loads the session in a same-origin iframe sized from the measured viewport; the embedded client lifts its cover on the launcher's viewport message; X-Frame-Options: SAMEORIGIN + frame-ancestors 'self' (cross-origin framing still blocked); ⧉ opens a windowed client on launch.html, which measures the popup before the session is created.

Review note: the header change from DENY to SAMEORIGIN is deliberate and scoped by frame-ancestors.

Testing

  • Automated: test_csp_allows_same_origin_framing_only.
  • Browser: zero-click-fullscreen.spec.mjs (same-tab overlay, connect carries the viewport and no screen size, embedded client in the frame, Exit tears down; non-fullscreen entries unaffected).
  • Manual: Safari and Chrome on a notched MacBook, RDP and SSH fullscreen entries: one paint, no snap; ⧉ opens a window that does not resize on load; Exit returns to the folder view.

The Content-Security-Policy had no img-src, so default-src 'self'
governed images and refused the data: URIs the Guacamole client draws
every tile from on browsers without WebCodecs ImageDecoder. Safari and
Firefox rendered SSH and RDP sessions black with only rect fills and the
cursor cell showing; Chrome decodes straight from the stream and never
fetches a URL, which hid it. The same directive blocks the remote mouse
cursor (cursor: url(data:...)) and blob: image URLs.

Move the policy into a documented constant, add img-src 'self' data:
blob:, and add a unit test that fails if img-src loses either source.
Document the proxy-side header replacement for releases up to v1.10.5.

Fixes sol1#249
… input after the transition

With fullscreen-on-connect, the first mousedown after CONNECTED is the
browser's fullscreen gesture, but Guacamole.Mouse forwarded it to guacd
as well, and the transition that followed swallowed its release. guacd
was left with the button held and the terminal selected text on every
pointer move; the same with a key press as the gesture.

Stop the gesture event before the display's handlers see it, and on
entering fullscreen release everything explicitly: keyboard.reset() and
a mouse state with no buttons at the last pointer position, so a release
lost for any other reason during the transition cannot stick either.

Fixes sol1#256
The 18px table with 20px cell padding and a separate cell each for
edit, clone and delete overran the main column by ~300px on a 1710px
MacBook display, cutting "delete" off the right edge.

Put the three admin actions in one cell, and add a max-width: 1800px
breakpoint on the connections page: 20px page padding, 240-280px
sidebar, 16px cells with tighter padding, 14px headers, a 110px
Connect button and a smaller folder title. The icon-only "open in new
window" button keeps its compact size. Nothing is hidden; at 1710px the
table fits with about 250px to spare.

Fixes sol1#252
The 18px tables with 20px cell padding ran past the right edge on a
1710px MacBook display, the recording link cut off. Same treatment as
the connections table: 16px cells with tighter padding, 14px headers,
actions kept on one line. Nothing is hidden.

Part of sol1#254
The 1800px breakpoint set a tighter page gutter on the connections page
only, so switching to Sessions or any other tab snapped the content
sideways. Move it to rustguac.css so every page moves together.

Part of sol1#254
A browser test harness under tests/e2e: serve.mjs serves the real static
tree over HTTP and lib/api.mjs answers /api/* from a small fixture address
book, recording every request the page makes, so the shipped pages run
against data the test controls without Vault, guacd or a session. CI gains
an e2e job that installs Chromium and runs the suite.

The first spec pins sol1#252: at 1710px the connections, recordings and reports
tables fit inside the viewport and nothing scrolls sideways.
Windows RDP without GFX rounds the requested width down to a multiple
of 16, so a 1710-wide window gets a 1696-wide desktop. The uniform fit
in scaleDisplay() then keeps the scale at 1 (the height matches) and
the missing 14px shows as a strip of bare page down the right edge.

After the uniform fit, stretch the display element by the remaining
fraction on each axis when it is at most 2.5%, and divide the same
factors out of pointer coordinates before sendMouseState. A larger gap
is a real aspect mismatch and keeps the letterbox. Multi-monitor
pop-outs reset the fill.

Fixes sol1#251
…rgin

Stretching the display to fill a mod-16 shortfall costs sharpness when the
picture is already drawn one framebuffer pixel per physical pixel, and
nearly every RDP session hits the rounding. So the fill now applies only
when the picture is already being resampled (scale x devicePixelRatio != 1),
where it costs nothing; a pixel-exact session is never stretched, and the
framebuffer is centred in the available area so the small margin is split
evenly in the dark background instead of showing as a bare strip down one
edge. offsetLeft includes the centring margins and ignores the fill
transform, so the pointer maths that divides out fillX/fillY is unchanged.

Fixes sol1#251
tests/client-fit.mjs lifts applyFill() out of client.html and runs it on a
stand-in element: a pixel-exact picture is never stretched, a resampled one
fills a gap of at most 2.5% per axis, a larger gap keeps the letterbox, and
nothing is ever shrunk through the fill. Run in the CI e2e job.
…ders

A device moved between NetBox tenants kept its connection entry in both
tenant folders: the webhook writes to the device's current tenant folder and
has no way to name the old one (its context carries only the old tenant's
id, not its slug).

PUT .../entries/:entry?move=true deletes same-named entries from every other
folder of the scope after the write, each deletion audited, so an upsert
keyed on a stable name follows its device when the folder changes. The
NetBox webhook examples use it.

Part of sol1#253
The flag defaults to off, accepts an explicit true or false, and rejects a
non-boolean value instead of treating it as true.
NetBox stores no secrets, so a synced entry was created without a password
or key and showed "Login..." until a five-minute timer added the fleet key.

Folders now carry default credentials: a per-session-type username,
password, private key or domain at <folder>/.defaults, lent at connect time
to entries that have no secret of their own, subfolders inheriting per type
(nearest folder wins). Listings report such entries as has_credentials with
inherited_credentials, so they show Connect from the moment a webhook
creates them. Admin API: GET/PUT/DELETE .../defaults (secrets write-only,
summaries never carry them, every change audited), and a Default
credentials section in the folder dialog. `.defaults` is hidden from entry
listings like `.config`.

Part of sol1#253
…argument defaults handler

Inserting the folder-defaults handlers above ab_list_all left its doc
comment stranded over a section banner, which clippy flags as an empty line
after a doc comment; the two lines move back onto the function they
describe. ab_put_folder_defaults takes eight extractors, one over clippy's
default, so it carries the usual allow like the other axum handlers here.
The page loaded folders, entries and credential status once and cached
them for the life of the tab, so a change made by a webhook, another admin
or another browser was invisible until a reload.

GET /api/addressbook/version returns a counter that middleware bumps after
every successful change made through the address-book API (entries,
folders, folder config, defaults); it costs no Vault read. The page polls it
every three seconds and reloads folders, entries, the search index and the
credential status only when the counter moves, plus an unconditional
refresh every two minutes for changes made directly in Vault or by another
instance. Nothing reloads while a dialog is open or the tab is hidden; a
hidden tab catches up when shown again.

Fixes sol1#253
…ol1#254)

With 20+ customer folders the sidebar shared the page scroll with the
entries table, so picking a folder far down the list changed content off
screen, and every leaf row was pushed ~90px in by a hidden chevron
placeholder while names were ellipsised.

- .sidebar is position: sticky with its own thin scrollbar and
  max-height tied to the viewport; .main keeps scrolling with the page.
- The FOLDERS header is wrapped in .sidebar-pin together with a new
  empty #sidebar-tools slot, both pinned at the top of the sidebar.
- buildFolderNode() renders a .tree-chevron only for folders with
  children; leaves get no placeholder (removed the .tree-chevron.leaf
  rule), so rows start right after the 3px accent bar.
- Name, entry-count pill (.folder-count, now just the number with a
  data-count attribute and "N entries" title) and a smaller scope glyph
  share one line; description sits beneath at --fz-xs; rows are ~40px.
- Nested .tree-children indent 14px behind the existing dashed guide.

All new CSS is in one block at the end of the page's <style>.
With a long folder list, clicking a folder appeared to do nothing: the
entries table was off screen, the only loading state was a text swap,
and nothing drew the eye to the changed title. loadEntries() also
called renderFolders() just to update one count badge, rebuilding the
whole sidebar on every click.

- Jump to the entries header when the shown folder changes (smooth,
  'auto' under prefers-reduced-motion); never on initial load, live
  refresh, forceRefresh after a mutation or search clear.
- .changed on #entries-header replays a 350ms title slide-in and an
  accent underline flash on the action bar; cleared on animationend.
- Skeleton table (4 shimmering rows, same geometry as the real table)
  while entries are fetched from the server; the cached path renders
  immediately with no skeleton.
- updateFolderCount() swaps the number in the existing .folder-count
  badge in place; renderFolders() only as a fallback when the badge
  does not exist yet.
- renderFolders() is wrapped (not edited) to preserve the sidebar
  scroll position across rebuilds and to reveal the selected row within
  the sidebar (nearest, no page scroll) when the selection changes or
  after loadFolders() rebuilds the tree.
…es (sol1#254)

The sidebar lists one folder per customer and gets long. Add a live
filter box (name, path, description; loaded subfolders included, with
ancestors of a match kept visible) mounted in a new #sidebar-tools div,
persisted in sessionStorage so live refreshes keep it. Make the tree
keyboard-navigable (arrows, Home/End, Enter/Space, Left/Right to
collapse/expand) with role=tree/treeitem, aria-selected/expanded and a
roving tabindex, and add an 'f' shortcut that mirrors the '/' one.

Implemented by post-processing the DOM renderFolders() builds and by
wrapping that function; its body is unchanged.
…sublines

The sticky sidebar was sized from the viewport rather than from where
it starts under the header, so at the top of the page its bottom hung
off screen and the page had to be scrolled before the list could be
scrolled to its end. The page is now an app shell: the header stays,
and the sidebar and the entries area are two independent scroll panes
that always fit the viewport. Selecting a folder scrolls the entries
pane, not the page.

The sidebar is a bordered panel: one pinned header band on a surface
tone with the Folders label, a compact new-folder button and the filter
box, then the list with hairline dividers, 46px rows and one 12px edge
for every band. "host (Tenant)" names read as the host over a quieter
tenant line in the entries and search tables, and a role-gate
reconciliation covers API-key users.

Part of sol1#254
jumpToEntriesHeader() used scrollIntoView(), which scrolls every
scrollable ancestor, and a body with overflow hidden can still be
scrolled by script: selecting a folder pushed the brand row and the nav
off the top of the page. Scroll the entries pane to its top instead, and
make the body overflow: clip, which no script can scroll. The body's
border box now spans the full height so the page padding stays inside
the viewport.

Part of sol1#254
The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.

Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.

Part of sol1#254
Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.

Part of sol1#254
The ad-hoc session form sat in a collapsible framed box above the session
list. It now opens as a modal from a button on the right of the Sessions
heading, dropping the page's framed form box inside the dialog, with the
primary button first and Cancel at the same height as Connect, matching the
other modals. Section headings centre their heading and action button on
one line.

Part of sol1#254
Session history and the most-used table showed "host (Tenant)" names
wrapped across three lines; they now read as the host over a quieter
tenant line, as on Connections and Recordings.

Part of sol1#254
…l1#254)

The sessions page printed the type as plain text and the recordings page
as a coloured word; connections and reports already used .type-badge
from rustguac.css. All four now use the same badge. The Proxmox badge
colour moves into the shared stylesheet with the others.
Shared facet filters (static/filters.js) on the sessions, recordings,
reports and connections pages: a dropdown per facet (Type, Folder, User,
Owner, Status as each page has them), multi-select with counts that narrow
as other facets apply, All/None, a find box for long lists, a Clear filters
link, and selections kept per page in sessionStorage. They sit beside the
existing text search and combine with it; the buttons are compacted to the
height and padding of the other controls and the dropdown reserves room for
its scrollbar so it no longer sits over the counts.

The list pages share one toolbar: a borderless row with the search box, the
facets and the page action on the right (New ad-hoc session, Export CSV),
placeholders standardised to 'Search <noun> (fields…)'. Sessions gains a
search (user, host, owner, ID, type) and a User facet; Reports moves from
its own .filter-bar to the shared one. The redundant page titles go — the
nav already names and marks the active page — and section headings centre
their heading and action button on one line.

Connections: the folder header is sticky at the top of the entries pane, so
it stays under the nav and the active-sessions strip while a long table
scrolls. All four pages show the session type with the shared .type-badge
(the Proxmox colour moves into rustguac.css), reports and recordings show
"host (Tenant)" names as the host over a quieter tenant line, and the key
glyph before Credentials in the nav is sized to the text.

Part of sol1#254
A folder can carry a display_name beside its description. It is stored
in the Vault .config (omitted when empty, so existing configs are
unchanged), returned in folder listings and GET .../config, and accepted
by POST /api/addressbook/folders and PUT .../folders/:scope/:folder.
The NetBox tenant sync sets it to the tenant name, so the connections
page can show 'Dani & Fiori's Gourmet Pizzeria' over the folder key
instead of three spellings of the tenant in one line.
A folder can carry a display_name beside its description. It is stored in
the Vault .config (omitted when empty, so existing configs are unchanged),
returned in folder listings and GET .../config, accepted by POST
/api/addressbook/folders and PUT .../folders/:scope/:folder, and carried
in the search index as folder_display_name so results can show it the way
the folder view does. The NetBox tenant sync sets it to the tenant name.

On the connections page a folder with a display name shows it as the title
with the folder key as a sub-header, in the header and the sidebar, so
'Dani & Fiori's Gourmet Pizzeria' reads over the key instead of three
spellings of the tenant in one line. The folder modal gets a Display name
field, asked for before the description.

Part of sol1#254
A legacy .config without display_name loads with an empty one and is
stored unchanged; a set name round-trips. The browser spec checks the header,
the sidebar and the folder modal field.
static/ui.js provides promise-based rgConfirm/rgAlert that show a themed
modal matching the app, injected self-contained so it works on any page
including the standalone client. Every native alert()/confirm() is
replaced: delete user and revoke token (admin), revoke token (tokens),
delete recording (recordings), popup-blocked (client), and terminate
session, delete entry, delete folder and the SSH host-key probe
(connections). Destructive actions get a red confirm button, the host-key
trust prompt an accent one.

From an independent review of the module: rgConfirm/rgAlert resolve any
already-open dialog before replacing it, so a second call can't leave the
first promise hanging; Enter acts on the focused button (Cancel cancels),
and Tab is trapped between the two buttons.

Part of sol1#254
…page

The '/' search box leaves the connections folder header for a bigger,
centred box in the brand row, inline with the logo. Results open in a dim,
blurred overlay over an untouched folder view: a centred panel with the
summary, the Type and Folder facets and the results table (fixed columns,
one line per cell, folder shown by display name over its key, Connect, new
window and open-folder gathered into one actions cell). Focusing the box or
pressing / opens it and lists every connection the user can see; typing
narrows it; Escape from anywhere, a mousedown outside the box and panel, or
opening a folder closes it (an open facet menu takes the first Escape).

static/search.js is the single implementation, used on every page
including connections, which dropped ~16k of duplicated markup, CSS and JS;
the styles live once in rustguac.css so the results look identical
everywhere. The index is cached in sessionStorage and only re-fetched when
the address book's version changes, so moving between pages is instant; the
connections page keeps the folder-specific hooks it needs (rgSelectFolder
selects a folder in place, rgSearchIndex feeds the credentials banner) and
opening a folder from another page navigates to connections with
#open=scope/path. The session type is escaped before it becomes a badge
class, and the module stands down cleanly on a page with no brand row.

Connections also gets a more generous content gutter, the folder action
buttons move down to the filter row beside Type, the folder title is inset
to align with the table, and the Connect button keeps its size and spins
in place instead of growing to 'Connecting...'.

Part of sol1#254
The global search lifts the brand row to z-index 300 so the box is not
dimmed by its own results overlay (200), but the page modals sat at 100, so
the search input showed through the ad-hoc session dialog and could still
be focused. Modal overlays now sit at 1000, above the search chrome and
below the themed dialogs (4000) and the embedded session (5000). A spec
pins it on the ad-hoc and folder modals.
…esh Pending one

A fullscreen-on-connect session was created at the connections page's
window size, re-sent after connect, then replaced by the fullscreen size
once the gesture landed, with the display scaled to fit in between and
refit after the server's rounding: four or five visible size changes for
one connection, and with H.264 passthrough every change rebuilds the
decoder.

The connect request now carries the viewport (width/height) and, for a
fullscreen bitmap entry, the screen size; initial_session_size() creates
the session at that size. Only RDP and VNC, where a server-side resize is
slow and visible, take the screen size — a terminal reflows instantly and
must stay crisp, so SSH keeps the window's size. Every RDP initial size is
rounded down to the 16px grid (round_down_mod16), fullscreen or windowed,
because guacd rounds the client's first post-connect size send to that
grid regardless; creation and first send then always agree, the
post-connect send stays and becomes a no-op. VNC keeps exact sizes. The
SSH creation log carries width and height, as the RDP one does.

A repeat Connect within a few seconds reattaches to the Pending session
the same user just opened for the entry (find_reusable_session) instead of
creating a duplicate; an older Pending — whose guacd may have died — is
left to the pending-timeout reaper and a fresh session is created, so a
stale, dead stream is never handed back.

Part of sol1#257
ab_connect_entry gained the sizing query and now takes eight extractors,
one over clippy's default; it carries the usual allow like the other axum
handlers.
…asured viewport

A browser only lets the tab that holds the Connect gesture go fullscreen;
opening a new tab drops that gesture, so a fullscreen-on-connect entry
always fell back to a manual click. Connect now fullscreens the CURRENT
tab on the click — the one moment a valid gesture exists — and loads the
session in a full-viewport same-origin iframe (rgLaunchFullscreen in
search.js, shared by the connections page and the global search): overlay,
spinner and an Exit button, torn down on Exit, on fullscreen exit or on a
session-end message from the embedded client. The client in ?embedded=1
mode skips all in-client fullscreen logic (the parent tab owns it) and
posts ready/end to the parent. X-Frame-Options becomes SAMEORIGIN (was
DENY) with CSP frame-ancestors 'self', so a same-origin page can host the
client in a frame while cross-origin framing stays blocked.

The final viewport size cannot be predicted (a fullscreen viewport on a
notched Mac is shorter than the screen), so it is measured: the launcher
enters fullscreen, waits until the viewport has held one size for 150ms,
then sends the connect with that width and height; a refused request
connects at the tab's size at once and a 2.5s cap never leaves the user on
a spinner. The embedded client shows its connect cover from load and lifts
it — then runs the post-connect fit sends — only on the launcher's
'viewport' message, so the user sees only the final picture. The ⧉ (open
in a new window) button opens the client explicitly windowed (fs=0) on a
small splash page, static/launch.html, which measures its own viewport
once stable and posts it to the opener (rgOpenSessionWindow); the session
is created at that size, so nothing resizes on load. No screen_* is sent
for a popup. When the browser refuses the load-time request, the next
click or key enters fullscreen, taken in the capture phase and never sent
to the remote, and everything is released once fullscreen lands.

Fixes sol1#257
The CSP test pins frame-ancestors to exactly 'self'. The browser spec drives
Connect on a fullscreen-on-connect entry: the launcher overlay opens in the
same tab, the connect request carries the measured viewport and no screen
size, the embedded client loads in the frame, and Exit tears it down.
@jseifeddine
jseifeddine force-pushed the feat/zero-click-fullscreen branch from 1828f80 to 8686684 Compare October 12, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RDP fullscreen-on-connect resizes several times before settling

1 participant