Skip to content

feat: lookout desktop handoff - #273

Open
anscg wants to merge 12 commits into
hackclub:mainfrom
anscg:lookout-desktop-handoff
Open

feat: lookout desktop handoff#273
anscg wants to merge 12 commits into
hackclub:mainfrom
anscg:lookout-desktop-handoff

Conversation

@anscg

@anscg anscg commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

hiho! this implaments lookout auth & lookout's panel handoff!

@Lamparter
Lamparter self-requested a review August 24, 2026 20:16
@anscg

anscg commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

hey @anscg can you review my pr?

@anscg

anscg commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

NO!

@anscg

anscg commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

actually let me think about it... sure!

@anscg

anscg commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

reviewing...

@anscg

anscg commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

reviewed! LGTM!

@Lamparter Lamparter changed the title feat: Lookout desktop handoff feat: lookout desktop handoff Aug 31, 2026
anscg added 10 commits August 31, 2026 17:18
The app can pair a device and start recordings without a browser hop, and it
renders our publish flow in a sheet instead of opening a tab. That last one
means publishing had to come apart: the panel opens when a recording is saved,
minutes before there is a video, so the user's answers are stored as a pending
intent on the draft and applied once Lookout finishes compiling. See
docs/lookout-desktop.md.
From a security review and testing the panel by hand:

- read-scoped callers no longer trigger a publish: the lazy finalize in
  getLookoutDrafts/pollLookoutStatus creates a timelapse and pushes
  heartbeats, and neither procedure declares timelapse:write. Gated on the
  scope; the sweeper still does it within a minute regardless.
- the panel refuses a second submission. The intent stays live for minutes
  while the video compiles, so the URL could otherwise be replayed to rewrite
  it - flipping an UNLISTED choice to PUBLIC, say.
- the sweep lock is a compare-and-delete on its own token. A sweep outliving
  the TTL would otherwise delete whichever replica's lock came next.
- session creation survives an older Lookout. Their create endpoint refuses
  unknown fields outright, so a Lookout that predates panels answers 400 to
  panelUrl and recording would break entirely between our deploy and theirs.
  Retries without it; losing the in-app panel for that window is a fair price,
  losing recording is not.
- the panel is transparent from the first paint, and follows Lookout's theme.
  globals.css gives html/body/#__next a dark background, min-height: 100vh and
  display: flex - all three wrong in a sheet, and the 100vh floor broke height
  reporting outright (body could never measure shorter than the frame, so the
  sheet could only ever grow). Reset in a styled-jsx global block, which Next
  inlines into the head, because clearing it from an effect is one paint too
  late and that paint is the flash. Light/dark comes from lookout_theme on the
  query.
- the name field is prefilled from the Lookout session, which is where the
  desktop app's stop dialog already put the title the user typed. Lookout's own
  untitled-YYYY-MM-DD placeholder is not offered back.
Verified against a real Lookout staging instance rather than assumed.

lookoutFetch sets Content-Type: application/json on everything, and Fastify
rejects a JSON content type with an empty body before the route runs. So
markPanelResolved 400'd every time - silently, since the failure is only
logged, surfacing much later as the desktop app still asking for details the
user had already given. Sends {} now; Lookout also treats empty as {} from
this point, so either side alone is enough.

The retry-without-panelUrl fallback stays but the reasoning was wrong: an
older Lookout does not 400 on the unknown field, it strips it and returns
201, so deploying this side first was already safe. Kept as insurance
against that default changing, not as a correctness requirement.
Found by actually running it, framed in the desktop app.

The panel never hydrated. It returned only a styled-jsx <style> element while
loading, which contributes no DOM - React had nothing to attach to, so no
effect ever ran, the fetch never fired and the page stayed blank for good. It
now always renders a real element, empty though it may be.

_app bounced it to /update-browser: the OPFS check applies to recording, and
the panel is a form embedded in another application that records nothing.
An embedded webview without OPFS could not publish a timelapse it had
already recorded, which is the one thing the panel exists for. Exempted
/lookout/* from that gate and from the login-time draft redirect, which would
otherwise send the panel off to some other draft's publish page.

The token comes from location.pathname instead of router.query. A dynamic
pages route leaves query empty until the router reports ready, and this page
loads in contexts where that does not happen.

NEXT_PUBLIC_API_URL was read with bracket access, which Next does not reliably
substitute at build time - so every request went to production rather than
wherever the build points. Dot access, matching api.ts.

Sizing, for a sheet rather than a page: the two steps shared a grid cell, so
the container was always as tall as the taller one and the reported height
never changed between them - the sheet sat at the Details height while showing
Hackatime. The inactive step is now out of flow. Visibility sits side by side
(a sheet is narrower than the sm breakpoint, so the responsive stacking always
triggered and cost double the height), our own "not now" is gone since the
sheet's close control already does that, and the whole thing is ~29% shorter.

Nothing is preselected for visibility any more, matching the website: who can
see this is a deliberate choice, not something to default past.

Plus: Phantom Sans is applied directly, since RootLayout is what normally
brings it and the panel deliberately does not use RootLayout; light mode no
longer paints secondary buttons near-black; "already answered" renders an
explicit all-done state instead of a blank page; and the panel announces
lookout:ready so the host can hold its own spinner.
The host keeps the frame hidden until the panel reports itself ready, and a
frame nobody paints does not animate - so rAF never fired, the height was
never reported, and the sheet sat at its minimum while the panel waited to be
shown. A timeout runs whether or not anyone can see us.
The Hackatime step was the tallest thing in the sheet, and most of that was
the project list: a 256px scroll area of 36px rows sitting over the desktop
app's window. Compact takes the list to 160px and the rows to 28px, so the
step now measures shorter than Details rather than a third taller.

The website keeps the roomy version - it has a page to spend, the panel has
a sheet.
Rows at 28px were tighter than they needed to be - 32px reads like the rest
of the app and the height came out of the list instead: four rows visible
rather than five, scrolling sooner in exchange for a shorter sheet.

Picking "make a new project" also hides the list and the search box. The
existing projects have stopped being choices at that point, and what follows
is a name field, so the sheet loses another 57px right where the user is
about to type.
Lapse is dark-only: globals.css defines one palette on :root. The panel is the
one page that has to follow someone else's chrome, and what it had was four
`light ? ...` conditionals on its own wrappers - so in Lookout's light theme
the text went dark and everything from a shared component stayed dark too:
inputs bordered in slate, muted labels at #8492a6, a grey-on-grey button.

Light mode is now a scoped redefinition of the palette variables. Tailwind
compiles every utility to a var() read, so TextInput, Button, VisibilityPicker
and the project picker all follow without knowing the panel exists.

Two things the scope cannot express are handled next to it: the --color-x:
var(--x) aliases in the theme are respelled with literals, because a var() in
a custom property resolves where it is declared and those resolved against
:root; and text-white, which is baked into shared components, is corrected
only where it now sits on a light fill.
It was the one class the compact work left ungated, so the website got it too.
Harmless in every layout I can construct - there is nothing to truncate in
there - but it lets a flex child shrink below its content, which is a real
change in wrapping behaviour for a page nobody asked me to touch.

With this the picker's non-compact class list is identical to main's.
… link as a fallback

The `listDesktopDevices` / `revokeDesktopDevice` endpoints existed and Settings > Devices was
the natural place to reach them from, but the tab only knew about legacy encryption devices -
so a paired install could only be revoked by re-installing the desktop app. Fixed.

The pair page also now shows the `lookout://pair` handoff link with a copy button once the
user has approved. On some Windows setups the OS never fires the deep link, and the code is
single-use and short-lived - pasting the URL into Lookout's + menu is the manual escape hatch
(same shape as the fallback on the recording page).
@Lamparter
Lamparter force-pushed the lookout-desktop-handoff branch from 4abea67 to 02b7137 Compare August 31, 2026 16:18

@Lamparter Lamparter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR was so well AI generated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this page kinda got messed up

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meffo

Comment thread docs/lookout-desktop.md
@Lamparter
Lamparter self-requested a review August 31, 2026 16:26
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.

2 participants