feat: lookout desktop handoff - #273
Open
anscg wants to merge 12 commits into
Open
Conversation
Lamparter
self-requested a review
August 24, 2026 20:16
Collaborator
Author
|
hey @anscg can you review my pr? |
Collaborator
Author
|
NO! |
Collaborator
Author
|
actually let me think about it... sure! |
Collaborator
Author
|
reviewing... |
Collaborator
Author
|
reviewed! LGTM! |
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
force-pushed
the
lookout-desktop-handoff
branch
from
August 31, 2026 16:18
4abea67 to
02b7137
Compare
Lamparter
reviewed
Aug 31, 2026
Lamparter
left a comment
Member
There was a problem hiding this comment.
this PR was so well AI generated
Member
There was a problem hiding this comment.
this page kinda got messed up
Lamparter
self-requested a review
August 31, 2026 16:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
hiho! this implaments lookout auth & lookout's panel handoff!