chore(deps): bump shell-quote and concurrently - #159
Open
dependabot[bot] wants to merge 94 commits into
Open
Conversation
Add JA4 TLS fingerprint telemetry per screenshot
- Share one reqwest::Client (OnceLock) across the upload pipeline, sleep recovery, and exit-pause instead of building a new client (fresh pool + TLS config + handshake) on every 60s tick. Timeouts are unchanged, now applied per-request. - Stop round-tripping frames through base64: capture returns raw JPEG bytes, base64 is encoded exactly once for the JS preview, and the upload body is bytes::Bytes so retry clones are refcount bumps instead of full-buffer copies. Previously every tick encoded, decoded, and re-cloned the frame. - Run capture_and_upload's screenshot on spawn_blocking like the capture loop already does, keeping capture + JPEG encode off async workers. - Use Triangle instead of Lanczos3 when scaling multi-source stitches, matching the filter the (far more common) single-source path already uses; use into_rgba8() moves instead of to_rgba8() full-frame clones. - Tray ticker: only touch the native tray when the formatted title actually changes (it has minute granularity, the ticker runs at 1s), and drop the per-second "tray-timer-tick" emit nothing listens to. - Scope the macOS App Nap / idle-sleep assertion to active recordings (tray-timer lifetime) instead of the whole process. Recording behavior is unchanged — captures are still never throttled, including while paused mid-session — but an idle Lookout no longer prevents the Mac from ever sleeping. - Remove dead capture.rs wrappers (take_screenshot_raw, take_stitched_screenshots) with no callers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
- Replace the per-second tray-state sync (3 IPC calls + a broadcast event every second, all session long) with event-driven syncs on screenshot count, pause/resume, and server time corrections. The tray window already extrapolates its clock from updatedAt and the Rust ticker owns the menu-bar title, so nothing visible changes. - Fix the tray-ready fallback emitting state without updatedAt, which made the tray window compute NaN seconds until the next sync (now the next sync can be a minute away, so it mattered). - Seed a freshly started Rust tray timer with the last known tracked seconds so the menu-bar time doesn't briefly reset on pause -> resume. - Pause the screen-preview polling loop while the window is hidden — each preview frame is a full native capture + JPEG encode nobody can see. Resumes instantly on visibilitychange. - Skip the 5s running-apps refresh in Settings while the window is hidden (it enumerates every window on the system). - Stop logging preview-frame fetches; at 1/s per source they drowned the 200-entry debug buffer that error reports are built from. Failures are still logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
- Tray tooltip ("Lookout — 12m recorded"): Windows doesn't render tray
titles at all, so until now Windows users had no way to see the
recorded time from the tray without clicking it.
- Show a pause glyph in the menu-bar title while paused, wiring up the
is_paused parameter update_tray_time already received but ignored.
- Remember the last monitor selection and preselect it in the source
picker when every remembered monitor is still connected — multi-screen
users no longer re-shift-click the same setup every session. Falls
back to the primary monitor as before.
- Add-session page: re-enable program buttons 15s after launching the
browser flow. If the deep link never came back (closed tab, changed
mind), they used to stay stuck on a spinner until you left the page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
Claude/lookout desktop qol perf f8z7o3
Sessions can now opt in (clips_enabled, set at creation, immutable) to uploading per-minute webm/mp4 clips holding ~20 frames instead of a single JPEG — same cadence, credit math, and rate limits either way. - shared: capture format constants/types shared by all uploaders - server: clips schema + 0015 migration, format-aware upload-url and confirm validation, integration tests - worker: stitch clip segments into the compiled timelapse - react: clipRecorder hook (MediaRecorder) wired into the uploader - desktop: runtime-configurable server URL (Settings -> Advanced) with probe-before-save, persisted in localStorage; naming prompt on stop
Replace the blocking launch-time update gate with a Ghostty-style flow: check at launch and every 30 min, download in the background, and show a titlebar pill with live progress (NumberFlow digits) that becomes 'Restart to Complete Update' once the bytes are on disk. install() stays behind the click since it exits the app on Windows. Dev builds expose __updatePillDemo() to preview the pill lifecycle. Boot is no longer gated on anything: macOS permission grants are cached after the first successful check and re-verified in the background, so the pre-flight spinner flicker is gone, and [boot] timing logs trace eval -> mount -> first frame. Also rework the session rename animation (react client): spring between measured pixel widths instead of non-interpolable CSS string values, colors via CSS transitions, and drop the 600ms icon-delay workaround.
Coolify's proxy routes the domain to the container over the Docker network, so publishing host port 3000 only conflicts with other services on the box. Same as docker-compose.prod.yml otherwise.
The Settings -> Advanced custom server feature was unusable: the tauri-plugin-http capability scope only allowed lookout.hackclub.com, so every fetch to a user-configured server was rejected client-side with 'url not allowed on the configured scope'. The server URL is user-configurable by design and the webview CSP already restricts connections to https (+ localhost for dev), so mirror that here.
Self-hosted deployments serve the admin panel from BASE_URL, but the CORS allowlist only accepted *.hackclub.com — so the panel's requests were rejected by its own server. Derive the deployment's hostname from BASE_URL and allow it alongside the existing origins.
- Raise clip bitrate 133k -> 400kbps and the server cap 2MB -> 4MB: 133kbps H.264 at 1080p was visibly soft, and the compile re-encode compounded it. Static content still lands far below the cap (VBR). - Worker: transcode each clip to its segment in ONE ffmpeg pass (decode -> retime by real frame count -> scale -> x264). Removes the intermediate JPEG round-trip: one fewer lossy generation per frame and ~40% less per-clip work. tpad clone-pad guarantees exactly 30 frames per segment. - Worker: pipeline downloads into segment builds (no barrier) - early units encode while later units download. - ClipRecorder: capture the short opening clip at ~1s cadence so the timelapse's first second carries ~6 frames instead of 2 (it rendered as a near-still). Reverts to the server cadence after the first cut. - SDK preview badge: 'Latest screenshot' -> 'Latest capture'.
Desktop now records clips (~20 frames/min, format=mp4) on sessions with clips enabled, using the OS hardware encoder - no bundled codecs: - macOS: AVAssetWriter (VideoToolbox), verified against the real encoder with ffprobe-checked output - Windows: Media Foundation sink writer (hardware MFT when available); COM initialized per pool thread; new CI job cargo-checks the target - Linux: GStreamer appsrc pipeline (already a dependency), preferring VA-API encoders with a superfast x264 software fallback The capture loop fetches the session's clipsEnabled/frameIntervalMs at start. Frames are captured every 3s through the same redaction-aware path as uploads and feed both the clip and the focus-gated live preview (preview frames ship at 854x480 to keep IPC light). The opening window captures at ~1s cadence so the first clip is as dense as the rest. Any clip failure (encoder, size cap, server downgrade) falls back to the legacy one-JPEG-per-minute upload for that interval. Also: SIMD downscale via fast_image_resize (29ms -> 7ms at 5K->1080p), single-copy BGRA conversion, and a settings Advanced page probe already in tree.
The compile step should not be a quality event: with clips already bitrate-capped at the client, re-encoding at CRF 28 added a visible second generation of loss. CRF 18 makes the segment encode perceptually transparent, leaving the clip bitrate as the only quality dial. ~2.5-3x larger output files; timelapses are short so absolute sizes stay modest.
Real-world 133k-era clips measured ~400KB/min and visibly soft — the per-frame bit budget is what buys text legibility, so: - Frame cadence 20/min -> 15/min GLOBALLY (CLIP_FRAME_INTERVAL_MS 4000, server-authoritative; all clients follow). Slightly less smoothness, ~33% more bits per frame. - Clip bitrate 400k -> 800kbps (~400KB available per 4s frame = JPEG-q85-class keyframes at 1080p); server cap 4MB -> 8MB. VBR ceiling only — static content undershoots heavily. - Pin encoder behavior explicitly: one IDR per clip + no B-frames + ~1fps rate-control hint on VideoToolbox; MF_MT_MAX_KEYFRAME_SPACING on Media Foundation. Regression test asserts exactly one keyframe per clip. - Docs/tests updated to the new numbers.
Serially awaiting the clip finalize+upload (~4-5s) stalled frame collection at every tick — a hole in the recording after each cut that compounds to minutes of missing screen time per hour. The tick now cuts the clip and spawns the upload as a background task; frame capture for the next clip resumes immediately. The wait loop gains a third select arm that applies the confirm when it lands (tray sync, nextExpectedAt refinement, error/termination recovery), with a provisional interval-based target until then. Strictly one upload in flight: the next tick settles the previous upload before cutting, preserving capturedAt monotonicity and rate-limit assumptions. A pending upload at cancel/stop detaches and finishes in the background so the final minute still lands.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
screenshotCount counts capture units (one per recorded minute). On clips sessions each unit is a ~15-frame clip, so 'screenshots' was misleading; 'captures' is accurate for both modes.
- desktop: Raycast-style SwiftUI popup on the gallery + button (borderless NSPanel, menu material, fling-in spring) via a new Swift FFI bridge - desktop: native SwiftUI menu-bar item with rolling-digit time (numericText) - server: programs.icon_url end-to-end — registry, admin API + page, and /api/programs; icons render in the + menu and AddSessionPage - server: sessions.redirect_url — creators can send users somewhere when their timelapse completes; desktop opens it on completion - react: Gallery onAdd passes the + button rect for popup anchoring
AsyncImage re-fetched program icons on every menu open, flashing the fallback SF Symbol for ~0.5s. The frontend now passes icon URLs to a new prefetch_add_menu_icons command when the registry loads; Swift warms an in-memory NSImage cache the menu rows read synchronously, so icons render on the first frame. AsyncImage remains as the cold-miss fallback.
No overlay titlebar there, so top-right overlapped the gallery header controls; the pill now floats bottom-left and slides in from the bottom. Co-Authored-By: Claude <noreply@anthropic.com>
Replicates the macOS native NSPanel add menu (AddMenu.swift) where SwiftUI is not available: translucent blurred panel anchored under the gallery + button, spring fling-in from the top-right, hover/arrow-key selection, Escape or click-away to dismiss, quick fade-out exit. Program icons warm the browser HTTP cache at registry load, mirroring the Swift-side icon prefetch. Co-Authored-By: Claude <noreply@anthropic.com>
An edit is a cut list of absolute wall-clock intervals stored on the session; one shared membership rule (ts in [start, end)) drives all three outputs consistently: the published video, /timings (cut captures excluded by default, so Hackatime forwarders honor edits with no changes), and trackedSeconds (reported as raw - cutSeconds; raw kept as uncutTrackedSeconds; cuts can only shrink time). - shared: cuts.ts (CutInterval, normalize, membership, kept ranges, cut-seconds math), new API types, constants - server: GET /units, PUT /cuts, POST /compile (5-recompile budget, instant un-cut no-op), timings filtering + includeCut, post-cut tracked time on all session responses, original-video purge 7 days after the last edit (privacy backstop) - worker: compile records video_units (video second i <-> wall clock) and always writes original.mp4; cut-compiles slice the original losslessly (IDR-aligned -ss + exact -frames:v copy per kept range -- concat inpoint/outpoint leaks ~2 B-frame-dts frames per boundary, caught by the frame-exact test) with a pinned-GOP re-encode fallback; assembly fallback now GOP-pinned too - react SDK: TimelapseEditor (region drag/handles/seek/snap, playback skips cuts, scrubbing passes through with removal overlay, filmstrip, gap markers), api client methods, Edit affordances in SessionDetail, LookoutRecorder (editing prop) and hosted web Result (?edit=false) - desktop: dedicated resizable 960x720 editor window (main window is a fixed 480x640), lookout-edited event refresh, capability additions - tests: shared cut math, editor math round-trips, real-ffmpeg lossless cut verification, endpoint integration suite; docs for API.md, integration.md, react API.md
Stopping now offers three choices — keep recording, stop & save, or edit &
save — instead of allowing edits after the timelapse is published.
`complete` is the status programs act on (forwarding heartbeats, accepting
submissions, firing the redirect hook), so a session must reach it exactly
once with its cuts already applied; editing afterwards would rewrite data
someone already consumed.
Mechanics: POST /stop {edit:true} sets an edit hold. The compile runs as
usual but the worker leaves the session 'stopped' with video_r2_key null —
built, not published. The owner previews that video, sets cuts, and
publishes; the lifecycle programs observe is unchanged (stopped ->
compiling -> complete, or straight to complete when nothing was cut). A
hold can only delay publication, never cancel it: the timeouts job
publishes as recorded after EDIT_HOLD_MINUTES, so an abandoned edit still
yields a timelapse.
- shared: EDIT_HOLD_MINUTES, StopRequest.edit, editable/editHoldUntil
- server: migration 0019, stop {edit}, hold-gated editability (no
post-complete edits — PUT /cuts 409s on published sessions), POST
/compile publishes (instant without cuts via lib/publish.ts, worker
handoff with them), hold-expiry auto-publish sharing that same atomic
helper so a user's publish and the job race safely
- worker: held builds stay unpublished; an edited publish deletes the uncut
original immediately rather than keeping a 7-day re-edit window
- react SDK: StopChoiceModal, editor reframed as the publish step (polls
while the preview compiles, counts down the hold), SessionDetail review
panel, LookoutRecorder routes Stop through the modal
- desktop: NamingModal gains Edit & Save; while the editor window is open
the main window shows only an icon and 'Edit your timelapse in the edit
window.', recovered by polling so it can never get stuck
- web: inherits the SDK flow; ?edit=false maps to editing={false}
- tests/docs reworked around the hold; dead web Result.tsx edit code
reverted (that component is unused)
dependabot
Bot
force-pushed
the
dependabot/npm_and_yarn/multi-bb2862ba2e
branch
from
August 9, 2026 06:02
bf9b4cd to
6a270ea
Compare
Two causes, both making the ring feel frozen after a smooth first couple of seconds. The first real /status value set a flag that switched the time estimate off for good. From that moment the only thing that could move the number was a poll — every 2s, and only when the worker had advanced another 1% (its write threshold), stopping dead at its 0.95 cap. So the ring went from five updates a second to one every two seconds in coarse steps, which reads as a hang. The second is mine: COMPILE_MS_PER_UNIT was 350, calibrated against the single-pass full-quality compile. The editor now waits on the PREVIEW tier, ~83ms of encode per unit against ~431ms for publish, and across an 8-way pool the marginal cost is dominated by the R2 fetch. At 350 the estimate ran ~4x too slow, so real progress overtook it immediately and the ring inherited the poll's granularity for the entire wait. Now 90. The fix is that neither source may silence the other. Both run every tick and the ring takes whichever is further along: - the estimate is continuous in time, so something is always moving and the ring can never sit still; - real progress pulls it up whenever the compile is ahead of the guess, so the number stays tied to the truth; - between polls it eases out of the last real anchor, bounded by what one poll interval is expected to deliver — so a stalled worker eases to just short of the next anchor and holds, rather than sailing to the cap. Monotonic, and now capped at 0.95 rather than asymptoting toward 1 — a ring sitting at 99% while the user waits was the thing that made a genuinely stuck compile indistinguishable from a slow one.
The Linux release job took 90 minutes on x64 and 142 on arm64, against 16-22 for Windows and macOS. 76 of those minutes were one step: the rpm bundler xz-compressing single-threaded, 06:24:56 -> 07:41:03 on the 0.3.7 build. The Rust compile was 9m26s and the deb took 13 seconds. Cause is 'debug = true' in [profile.release], added in 0.3.5 to emit the Windows .pdb — which is exactly when the Linux job went from 12.8 minutes (0.3.4) to 99 (0.3.5). Its comment claimed debug info 'ships only in the separate .pdb, never in the released binary'. True on MSVC, and on macOS it goes to a .dSYM outside the .app, but on Linux it is embedded straight into the ELF. The 0.3.7 assets show it plainly: Linux .deb / .rpm 136 MB Arch .pkg.tar.zst 125 MB macOS .dmg (UNIVERSAL) 20 MB Windows .exe 6 MB So Linux was shipping a 20x binary, and users pull all of it through the updater on every release — the CI time was the visible half of the problem. release.yml now sets CARGO_PROFILE_RELEASE_DEBUG=false on the Linux legs only, so Windows keeps the .pdb this setting exists for and macOS keeps its .dSYM. The Cargo.toml comment is corrected, since believing it is what kept this hidden. Expect Linux bundles around 10-15MB and the job back to roughly its 0.3.4 duration. Unverified until the next tagged build — the numbers above are measured, the improvement is predicted.
…le-size Fixes the compile progress ring stalling once real worker progress arrived, and stops Linux bundles carrying embedded debug info (136MB .deb/.rpm, and 76 minutes of single-threaded xz in the rpm bundler).
0.3.7 added Depends to the Linux bundles for the first time — pipewiresrc and the GStreamer plugins the app needs at runtime. That closed a real gap (the package previously declared nothing, so capture could simply not work) but opened a sharper one: a name that does not exist in the archive makes the package UNINSTALLABLE, which is worse than the bug it fixed, and building cannot catch it. Only installing can. So the Linux legs now install the built .deb in a clean ubuntu:24.04 container. apt resolving every Depends is the assertion; it also checks the shipped executable has all its shared libraries. Every name is read back out of the package rather than assumed — package name via dpkg-deb, executable via dpkg -L — so this cannot start failing spuriously when the product or binary name changes. A verification step that cries wolf gets deleted, which would leave the gap open again. I verified the current names resolve (all five Debian names on noble, all five Fedora names via mdapi), but a name check is not an install check, and the rpm Requires in particular can only be proven by installing.
The Linux release legs now install the built .deb in a clean ubuntu:24.04 container, so a dependency name that does not resolve fails the release instead of shipping an uninstallable package.
The desktop UI was tuned for macOS and nothing adapted: a GTK titlebar sat on top of the app's own header, the window was pure black or pure white, the accent was a fixed blue, and the type was a bundled webfont. Next to any Adwaita app it read as a web page in a window. So on Linux — and only on Linux; macOS and Windows are untouched: - The main window and the editor drop their GTK decorations and draw a flat header bar instead. Title and subtitle on the leading edge, one close button trailing, no fill and no divider, so the bar reads as the top of the window rather than a strip bolted to it. The gallery's title row folds into it instead of stating the page twice. - Undecorated windows lose their resize borders, so the editor redraws them: eight grab strips that hand off to the compositor's own resize-drag. Corners square themselves when the window is maximized or snapped, since rounding there leaves notches of desktop showing. - Adwaita's palette overlays the shared tokens — #242424 and #fafafa surfaces rather than #000 and #fff. - The session's accent colour, UI font, and window-button edge come from GSettings, so the app follows the user's own settings the way a GTK app does. Every read fails soft to today's behaviour off GNOME. The version moves out of the window title and into the header subtitle, which is where a GNOME app would put it.
The header bar was carrying only the window title, so every page still drew its own back button and heading underneath it — the duplication the header bar was meant to remove. Fold both upward, the way a GTK app does. App derives a title and back action per route, and a mounted page can publish an override through a small context (headerNav.tsx). That indirection earns its keep in exactly one place: Settings has subpages, and only Settings knows whether "back" currently means the settings menu or the gallery. The publisher is tracked by owner id so the outgoing page of a route transition can't clear the header the incoming one just set. In-content chrome is suppressed on Linux only: PageLayout's and PageChrome's back buttons, PageChrome's heading, RecordPage's Gallery button, and SessionDetail's back button (behind a new `showBack` prop, matching how Gallery's `showHeader` already works). The hero titles PageLayout draws stay where they are — those are the page speaking, not chrome. Also shrinks the gear and + glyphs, which read heavier than the close button sitting beside them.
The page actions were 6px rounded rectangles sitting next to a circular close button — two button systems in one bar. libadwaita rounds header-bar buttons fully, so match it. Also stop the pointer cursor following every control around, which GTK never does and which quietly reads as "web page" the whole time you use the app. The pointer is set inline in a couple of dozen components, so this is one scoped !important rather than a per-platform fork of each of them. The selector deliberately doesn't match everything: the editor's eight resize handles and its scrub cursors carry real information and keep their own values.
Phosphor's filled gear carries more ink than the plus at the same nominal size, so matching their size values left it looking the larger of the two.
Two things were wrong with where the pill sat. It was floating bottom-left on Linux, under a branch whose comment said there was no titlebar to put it in. That stopped being true when the header bar landed — the pill's whole design is a titlebar widget, so on Linux it now goes where it does on macOS. Windows keeps the floating fallback, which is still correct there. And it rendered on every page. It talks about the app as a whole, so it belongs on the app's home screen rather than following you into a recording or the settings tree. Gated on the same condition as the gallery's own header actions, which also excludes the permission gates and the editor-open placeholder. That part applies to all three platforms.
Leaving Settings swapped the title to "Lookout" immediately but left the back chevron sitting there for a few hundred milliseconds. The title is derived from the route, so it turned over on the same frame as the navigation. The back action came from what the page had published, and the outgoing page stays mounted for the length of its exit animation — so its contribution outlived the route by exactly one transition. Stamp each published contribution with the route it was published for and ignore it once that route is no longer current. Clearing on route change in an effect would not have worked: child effects run before parent ones, so the incoming page publishes first and the clear would wipe it. The route's own defaults cover the gap until the incoming page publishes, so nothing flickers on the way in either.
Two things, both about a surface that was borrowed from macOS. The dark fallback was #000000. On macOS and Windows the window is usually wearing vibrancy or mica and that colour never shows — but those materials drop out (remote sessions, transparency off, an unsupported compositor) and the flat colour underneath is what the user is left with. Pure black as that fallback reads as a hole in the screen rather than a surface. Warm near-black instead, and a warm off-white on the light side. This is the shared theme, so it lands on the web client too. Linux keeps Adwaita's #242424, which is the colour GNOME apps actually are. The add menu was a deliberate DOM replica of the macOS NSPanel — blurred, saturation-boosted, springing in with overshoot. On Linux it now wears a GTK popover instead: solid panel, 12px, a quiet two-stop shadow, 6px item rows, and a settle rather than a bounce. Behaviour is untouched; the popup itself, its keyboard handling and its anchoring are shared.
…lash
Adopts Adwaita's palette as the shared baseline on every platform — #242424
over #1e1e1e views, #fafafa over white — rather than pure black and pure
white. On macOS and Windows the window usually wears vibrancy or mica and
these never show, but those materials drop out (remote sessions,
transparency off, an unsupported compositor) and the flat colour underneath
is what the user is left with; #000 there reads as a hole in the screen.
This lands on the web client too. The Linux override file shrinks to what is
actually Linux-specific, since its palette had become a copy of the default.
The add menu was a deliberate DOM replica of the macOS NSPanel. On Linux it
now wears a GTK popover: an elevated #383838 surface (lighter than the
window, not darker — it took building it wrong to notice), a slight vertical
gradient, roomy 32px rows, a light edge in dark mode, a soft shadow, and a
tail that points at the button and that the panel scales out of. The popover
shifts rather than sliding its tail inward, so the tail lands on the button's
centre instead of ~9px wide of it. Behaviour, keyboard handling and the
open/close animation stay shared with every other platform.
Also fixes two flashes on launch, both the same mistake in different places:
styles injected from a useEffect, which runs after the first paint.
- The window flashed white: body resolves its colour from a custom
property that does not exist until theme.ts injects, so the first frame
fell through to the webview's own white. index.html now paints that
frame itself, on html rather than body so the var-based rule still wins
afterwards, and on #root for Linux so the corners are round immediately.
- The header buttons flashed white: their rules, and the tokens they read,
both arrived an effect late, leaving one frame of unstyled <button> in
the UA stylesheet. Both sheets now inject on import, as theme.ts does.
And the add menu's "create new" entry said "Create new timelapse" in the DOM
popup but "Start from link" in the macOS native menu — same action, two
names depending on the OS. Now both say the latter.
… backdrop state A window is normally exactly the size of its content, which leaves nowhere to put an outer border or a shadow — both paint outside the content box, off the window, where the compositor clips them. Neither could be done from the webview as things stood, and mutter does not shadow our undecorated window for us. So reserve the space, which is what GTK does: each window grows by 20px per side and keeps that frame transparent. The visible window insets into it, and the border and shadow finally have somewhere to land. The main window needed its min and max widened before the resize — it is pinned at 480x640 by min == max, so a set_size past the maximum would have been clamped straight back — then re-centred, since it grew around its old top-left. The frame pays for itself twice: it is also the invisible border you grab to resize, exactly as in GTK, so the editor's grab strips now span it instead of hugging a 6px edge. The border is a 1px spread ring rather than a real border, so it stays off the content box and follows the corner radius, including when that goes to zero on snap. The shadow is wide and faint rather than tight and dark — the tempting reading off a screenshot is far heavier than GNOME's and ends up looking like a drop shadow on a web card. Both the shadow and the header bar now dim when the window loses focus, GTK's :backdrop state. Driven by one class on the document rather than per-component opacity: they are the same signal, and two sources is how they drift apart. Linux only throughout — the frame is applied under cfg(linux) natively and scoped to html.os-linux.lookout-csd in CSS, and HeaderBar only ever renders on Linux.
Three fallout items from reserving a transparent frame around the window. The bottom of every page was clipped. The app shells size themselves with 100vh, which is the whole window — and that now includes the 20px frame on each side, so each shell ran 40px taller than the box it lives in and #root's overflow:hidden ate the difference. Pages with a pinned bottom action lost it. They size to 100% instead, which resolves against #root on every platform. Modal backdrops covered the frame. They portal to <body> and pin to the viewport, so the dim painted over the shadow and squared off the rounded corners. They carry a data attribute now, and the CSD window pulls them in to the visible bounds — !important, because they set their own inset inline and inline otherwise beats any stylesheet. The titlebar dimmed while the window was being dragged. Dragging takes a pointer grab, which costs the webview its DOM focus, so document.hasFocus() went false even though the window had never stopped being focused. Ask the window instead, via onFocusChanged, and keep the document as the fallback for hosts with no window events.
The transparent frame reserved for the border and shadow is invisible but still part of the window, so clicking what looks like empty desktop beside the app focused Lookout. Fixed with a GTK input shape: a region marking which part of the window accepts pointer input, everything outside it passed through to whatever is behind. The region deliberately stops short of the visible window — a band of the frame nearest the content stays live, because that band is the invisible border you grab to resize. Excluding the whole frame would have made the window click-through AND impossible to resize. The resize strips move into that band accordingly; out at the window's real edge they would sit in passed-through space and never receive a click. The corner zones are kept small on purpose, since they reach back over the visible window and a larger value starts swallowing the header bar's own buttons. Driven from the frontend, which already knows when the frame collapses (a snapped window is flush with the screen and has none) and already listens for resizes — the shape is computed from the window's size, so it has to be redone on both. That leaves the native side a pure "apply what I'm told" command with no state of its own to fall out of sync. gtk is pinned to the same minor Tauri links against; a different one would give us a second, incompatible ApplicationWindow and gtk_window() would stop unifying.
A finished timelapse played back blocky and smeared on Linux while every
other surface looked fine.
The cause is a launch-crash workaround: WebKitGTK's accelerated compositing
crashes under Wayland on some drivers, so it was being disabled for all
Wayland sessions. That is not free — without it WebKitGTK falls back to a
software path, and video goes through that path too.
Narrowed to the drivers that actually need it. NVIDIA's proprietary driver
is detected by two paths only it creates, so their absence is a reliable
"not NVIDIA" — nouveau, AMD and Intel never produce them, and reading a path
is cheap enough for the startup path, before the webview exists.
Anyone who hits the crash on some other driver keeps the escape hatch, since
a value the user set is still left alone:
WEBKIT_DISABLE_COMPOSITING_MODE=1 lookout
Note this does not help NVIDIA-on-Wayland, which still needs the workaround
and so still gets the software video path. Fixing that properly means
finding out what actually crashes rather than turning compositing off.
Linux clips encoded blocky and smeared. The corruption is in the file
itself, not in playback — it survives the round trip to R2.
The clip bitrate is deliberately derived from the cadence:
bitrate = CLIP_FRAME_BYTE_BUDGET * 8 * 1000 / interval_ms
so that bitrate x interval lands 400 KB in every frame whatever the cadence.
An encoder can only honour that if it knows how long a frame lasts, and the
GStreamer pipeline was declaring framerate=0/1 — variable — while a comment
claimed the interval was unused because timing rides on each buffer.
It is not unused. Told nothing, x264enc assumes a normal video cadence and
spreads the budget across ~30 frames a second, so a clip at one frame per 4s
was given roughly 3 KB a frame instead of 400 KB. Two orders of magnitude
under target, which is exactly what it looked like.
macOS and Windows never had this: both hand their encoders the real frame
rate (Media Foundation is explicitly given 1000/interval_ms). Linux now
declares the same cadence in the appsrc caps.
Buffers also carry a duration now. Without one mp4mux infers each sample's
length from the following frame's timestamp, which leaves the last frame of
every clip zero-length.
…at breaks a capture Two strands, committed together at the author's request. Window frame (Linux only) The transparent frame around the window grows from 20px to 40px a side. A box-shadow reaches offset + blur past its box, so the previous shadow needed 54px and the window edge sliced it off — a soft shadow with a hard straight line through it. The frame is now sized against the shadow, and that invariant is written down where WINDOW_MARGIN is defined, because raising one without the other silently brings the clipping back. Whether the frame should exist at all is now the compositor's call, read from GDK's window state rather than compared against the work area. That matters most under a tiling window manager, where every window is WM-sized and a 40px frame is not a shadow but a band of desktop wedged between neighbours. The old geometry check could not see a quarter-tile, which matches nothing on either axis; the granular per-edge tiled bits can, and need no window position, which Wayland will not give a client. The check survives only as a fallback. It applies to the main window too — fixed size hints do not stop a tiling WM sizing a window. Caveat worth knowing: those bits come from xdg_toplevel's tiled states, so this is real on Wayland and expected to be inert on X11 tiling WMs, which resize windows without advertising any tiled state. Platform detection moves into <head>, above the stylesheet. The rules keyed on .os-linux decide whether the window is opaque or transparent, and a class added at the end of <body> arrives after the browser has painted — which showed as a fifth of a second of solid, square window on every launch. Also: the gallery's bottom fade is dropped on Linux, where the list ends against the window's own rounded edge, via a showBottomFade prop matching the existing showHeader. Capture diagnostics A capture failure on Linux surfaces a D-Bus or GStreamer string naming an interface rather than a package. A new probe reports which of the three pieces of system software involved — portal, portal backend, PipeWire — is actually missing, and the frontend turns that into steps a person can follow.
deb/rpm installs already ship a desktop file that claims lookout://, but every launch also called register_all(), which writes a second lookout-desktop-handler.desktop claiming the same scheme. Ubuntu's "Open with" chooser then listed two Lookouts, one of which didn't forward the URL. Only register at runtime for AppImages and dev builds, and delete the stale duplicate that earlier versions left behind.
The previous fix left the packaged desktop file as the sole lookout:// handler, but Tauri's stock template writes Exec without %u, so that entry launches the app and silently drops the URL — the runtime-registered handler it deleted was the one that worked. Ship a custom desktop template (identical to upstream plus %u) for deb and rpm so the single remaining entry actually loads the timelapse.
Deleting lookout-desktop-handler.desktop left mimeapps.list pointing lookout:// at a file that no longer exists. GIO skips missing defaults, but not every launcher does — unregister the scheme so the packaged desktop entry is unambiguously the handler.
The browser SDK's clips were structurally softer than the desktop's: getDisplayMedia pre-scaled the track to 1080p with an uncontrollable filter (killing Retina text before capture), and MediaRecorder's realtime rate control ignores wall-clock frame spacing entirely, so sparse screen frames were quantized as coarsely as the encoder allows. ClipRecorder now prefers a WebCodecs engine (VideoEncoder + mp4-muxer): real per-frame presentation timestamps, per-frame-quantizer constant quality on Chromium (CLIP_WEBCODECS_QP), and the desktop's exact VideoToolbox config (derived VBR bitrate, integer-fps hint, realtime mode) on Safari. Capture is now unconstrained — native pixels in, with a stepped-halving downscale (hqScale) shared by clips and JPEGs. Measured in Chromium on worst-case 1080p text: flat 36.4-38.0 dB PSNR with all frames kept, vs MediaRecorder decaying 42.9->36.0 dB across the clip and silently dropping 2 of 7 frames (including the first). Output verified against ffprobe/ffmpeg — the worker's own tools. Everything fails soft: unsupported config or any mid-session error latches the session back to the unchanged MediaRecorder path, and an unusable cut still falls back to a single JPEG so the minute credits. Oversize clips coarsen QP (+6, capped) mirroring the bitrate backoff, with the same don't-punish-a-network-stall rule. No server or worker changes: same mp4 format, same MAX_CLIP_BYTES contract.
… stops useLookout fetches the video URL the moment a session flips to complete and puts it in state.videoUrl. LookoutRecorder never passed it to ProcessingState, which needs it to tell "still compiling" from "compiled, and here it is" — so the end of every session was a spinner reading "Loading video...", indefinitely, with the finished video already in hand. Reproduced against a session reporting complete with a ready video URL: the spinner was still there after 5s and no <video> element was ever mounted. With videoUrl threaded through, the same session renders the player. Hits every consumer of the widget, not one page: the web client and any program embedding <LookoutRecorder> were all ending sessions this way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pointed POST /api/internal/sessions has always returned `https://lookout.hackclub.com/session?token=...` as sessionUrl, and the server served the download landing page there and dropped the token on the floor. Every program handed that URL to a user was sending them to a page that said "Download Lookout" and forgot which session they came for. This is the page that URL promised. A token in, a status read, then one question — desktop app, this browser, or a camera — and then the SDK's own <LookoutRecorder> with the answer. The chooser has to live above LookoutProvider because capture mode is provider-level config: the recorder reads capture.mode and adapts, it doesn't switch. Recording, and nothing else. No gallery, no session browser. A token is a capability that names one session, and a page that turned one into a browsable archive would be a different product with a different threat model. Sessions still can't be created here either — that needs an API key. Options that can't work aren't offered: mobile browsers expose getDisplayMedia and then reject the call, and there is no mobile app to hand a deep link to. The three dead ends the chooser can't help with (no token, a token the server doesn't know, a session already finished) say so in a sentence instead of showing a recorder that cannot work. Served out of the server image, built in its own Docker stage: the runtime image copies node_modules wholesale from `builder`, so keeping vite and the React toolchain out of that tree is the difference between shipping them and not. It consumes packages/shared as a built artifact rather than rebuilding it, because shared's tsc build compiles its own tests and so needs vitest, which this stage's dependency subset doesn't carry. The SPA fallback is now path-aware, since two apps share the origin: the landing page at the root, this under /session. Verified against real @fastify/static — /session?token= and nested assets resolve, /sessions is deliberately not a match, and /api/* still 404s as JSON. Docs mention it rather than recommend it; it isn't the integration path most programs should reach for first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together. Updates `shell-quote` from 1.8.3 to 1.9.0 - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](ljharb/shell-quote@v1.8.3...v1.9.0) Updates `concurrently` from 9.2.1 to 9.2.4 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](open-cli-tools/concurrently@v9.2.1...v9.2.4) --- updated-dependencies: - dependency-name: concurrently dependency-version: 9.2.4 dependency-type: direct:development - dependency-name: shell-quote dependency-version: 1.9.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com>
dependabot
Bot
force-pushed
the
dependabot/npm_and_yarn/multi-bb2862ba2e
branch
from
August 13, 2026 18:18
6a270ea to
58ea989
Compare
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.
Bumps shell-quote to 1.9.0 and updates ancestor dependency concurrently. These dependencies need to be updated together.
Updates
shell-quotefrom 1.8.3 to 1.9.0Changelog
Sourced from shell-quote's changelog.
Commits
db09fc7v1.9.07ff5488[Fix]parse: finalize tokens in linear time (GHSA-395f-4hp3-45gv)b4bafa2[actions] Windows + node 5/7: install deps with a modern node3fb739d[actions] Windows + node 4/6/7: pin eslint to 9 before install, since npm 2/3...abe0163[actions] retrynpm installon Windows to survive npm 2/3 staging-rename flake7a76c1a[Fix]quote: escape leading~to prevent shell tilde-expansion75e8497[actions] update workflowsdca6e21[New] add types9aa9e8f[Dev Deps] updateeslint9ba368a[Dev Deps] apparentlyjackspeakis no longer in the graphUpdates
concurrentlyfrom 9.2.1 to 9.2.4Release notes
Sourced from concurrently's releases.
Commits
6c27ec09.2.43beb683deps: upgrade shell-quote to 1.9.0 (#599)e63e7029.2.37a7d6c3ci: fix publish syntax5b6e5f19.2.2520d928ci: publish to latest only from maind947273ci: use node 24 in the release workflow51e542bci: configure trusted publisher flow47dfb64deps: bump shell-quoteMaintainer changes
This version was pushed to npm by GitHub Actions, a new releaser for concurrently since your current version.