Skip to content

chore(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 6.0.5 in /clients/desktop - #181

Open
dependabot[bot] wants to merge 69 commits into
mainfrom
dependabot/npm_and_yarn/clients/desktop/vitejs/plugin-react-6.0.5
Open

chore(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 6.0.5 in /clients/desktop#181
dependabot[bot] wants to merge 69 commits into
mainfrom
dependabot/npm_and_yarn/clients/desktop/vitejs/plugin-react-6.0.5

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bumps @vitejs/plugin-react from 4.7.0 to 6.0.5.

Release notes

Sourced from @​vitejs/plugin-react's releases.

plugin-react@6.0.5

Fixed the react compiler preset filter to be linear (#1353)

The improved filter in v6.0.3 was non-linear and caused a performance regression (#1349). The filter was changed to be linear to avoid that.

plugin-react@6.0.4

Fixed $RefreshSig$ is not defined error when running vite dev with NODE_ENV=production

When running vite dev with NODE_ENV=production, the app errored with $RefreshSig$ is not defined. This error is now fixed.

plugin-react@6.0.3

No release notes provided.

plugin-react@6.0.2

Allow all options in reactCompilerPreset (#1189)

This is a type only change. Only compilationMode and target options were available for reactCompilerPreset.

plugin-react@6.0.1

Expand @rolldown/plugin-babel peer dep range (#1146)

Expanded @rolldown/plugin-babel peer dep range to include ^0.2.0.

plugin-react@6.0.0

Remove Babel Related Features (#1123)

Vite 8+ can handle React Refresh Transform by Oxc and doesn't need Babel for it. With that, there are no transform applied that requires Babel. To reduce the installation size of this plugin, babel is no longer a dependency of this plugin and the related features are removed.

If you are using Babel, you can use @rolldown/plugin-babel together with this plugin:

 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
+import babel from '@rolldown/plugin-babel'
export default defineConfig({
plugins: [


react({



  babel: {



    plugins: ['@babel/plugin-proposal-throw-expressions'],



  },



}),





react(),



babel({



  plugins: ['@babel/plugin-proposal-throw-expressions'],



}),

]
})

... (truncated)

Changelog

Sourced from @​vitejs/plugin-react's changelog.

6.0.5 (2026-07-30)

Fixed the react compiler preset filter to be linear (#1353)

The improved filter in v6.0.3 was non-linear and caused a performance regression (#1349). The filter was changed to be linear to avoid that.

6.0.4 (2026-07-22)

Fixed $RefreshSig$ is not defined error when running vite dev with NODE_ENV=production

When running vite dev with NODE_ENV=production, the app errored with $RefreshSig$ is not defined. This error is now fixed.

6.0.3 (2026-06-23)

Improve the react compiler preset filter to reduce false-positives (#1138)

Improved the filter in the react compiler babel preset to reduce the false-positives so that less modules are processed by the react compiler.

6.0.2 (2026-05-14)

Allow all options in reactCompilerPreset (#1189)

This is a type only change. Only compilationMode and target options were available for reactCompilerPreset.

6.0.1 (2026-03-13)

Expand @rolldown/plugin-babel peer dep range (#1146)

Expanded @rolldown/plugin-babel peer dep range to include ^0.2.0.

6.0.0 (2026-03-12)

6.0.0-beta.0 (2026-03-03)

Remove Babel Related Features (#1123)

Vite 8+ can handle React Refresh Transform by Oxc and doesn't need Babel for it. With that, there are no transform applied that requires Babel. To reduce the installation size of this plugin, babel is no longer a dependency of this plugin and the related features are removed.

If you are using Babel, you can use @rolldown/plugin-babel together with this plugin:

 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
+import babel from '@rolldown/plugin-babel'
export default defineConfig({
plugins: [


react({



  babel: {



</tr></table>

... (truncated)

Commits
  • 68c0cb8 release: plugin-react@6.0.5 (#1362)
  • 555cdbc fix(react): make the react compiler preset filter linear (#1353)
  • a00a9f8 fix(deps): update all non-major dependencies (#1327)
  • f4b5498 release: plugin-react@6.0.4
  • 7a40659 fix(react): $RefreshSig$ is not defined with NODE_ENV=production vite dev ...
  • 98b32d4 fix(deps): update react 19.2.8 (#1298)
  • 8ae5449 fix: babel-plugin-react-compiler cannot be imported when used in a framework ...
  • f09ea01 fix(deps): update all non-major dependencies (#1282)
  • 640fd35 release: plugin-react@6.0.3
  • 889efb0 fix(deps): update all non-major dependencies (#1249)
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for @​vitejs/plugin-react since your current version.


anscg and others added 30 commits July 23, 2026 10:40
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
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.
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)
Clicking 'Edit & save' failed immediately with 'this timelapse isn't
available for editing'. The worker claims the compile within a second of
the stop, flipping the session to 'compiling' — and sessionEditability
only tolerated 'stopped', so the state the editor almost always opens
into was reported as terminal.

- server: 'compiling' with a live hold is now 'preparing' (a wait, not a
  failure), a failed compile reports 'failed' rather than a generic
  not-ready, and /units returns expectedUnits so clients can size a
  progress estimate
- server: POST /compile during 'preparing' drops the hold and returns 200
  instead of 409 — 'publish as recorded' shouldn't make the user wait for
  a preview they just declined; the in-flight build publishes when it
  lands
- react SDK: new ProgressRing primitive; the editor and the SessionDetail
  review panel poll through 'preparing' behind a live ring sized from the
  session's capture count. It's a time estimate, so it eases toward but
  never reaches 100% — only the video actually landing completes it
- SessionDetail no longer treats a failed compile as a hold to wait on
- regression tests cover the compiling-with-hold state, the failed state,
  and publishing mid-compile
… CORS headers

The filmstrip and hover scrubber read pixels through a canvas, so the
offscreen <video> was loaded with crossOrigin="anonymous". That is
all-or-nothing: a presigned URL served without CORS headers doesn't taint
the canvas, it fails the load outright — so a bucket without a GET CORS
rule produced no track thumbnails and no scrubber preview at all, with
nothing in the UI to say why.

Now the frame source is resolved in two steps: probe the URL with CORS,
and if that fails, pull the bytes through the app's fetch (Tauri's HTTP
plugin on desktop, which isn't subject to browser CORS) and use a blob:
URL, which is same-origin by definition. Both paths log which one was
taken, and only a failure of both degrades the timeline to a plain track.

Also splits the hover scrubber onto its own decoder so it no longer
competes with the filmstrip pass for currentTime, and gives the preview
card a bare time-chip fallback so hovering still says where you are.
…ce resolution

Two separate defects behind 'why is the preview so low quality'.

Resolution: every thumbnail canvas was sized in CSS pixels, so on a 2x
display the browser scaled a half-resolution bitmap up. Backing stores are
now device-pixel sized (capped at 2x), drawn with high-quality smoothing,
and the strip JPEG went 0.6 -> 0.82.

Geometry: the strip packed one tile per capture unit into equal flex
cells, so a 48-minute session squeezed each frame into ~19px and cropped
it with background-size:cover — a smear, not a filmstrip. Tiles are now
whole uncropped frames at the video's own aspect ratio, and the count
comes from the track instead of the recording:

  tileW = STRIP_HEIGHT x (videoW / videoH)   ~= 100px at 16:9
  tiles = ceil(trackW / tileW)               ~= 9 across a 900px track
  tile i samples t = clamp(((i + 0.5) x tileW) / trackW) x duration

The last tile overruns the track and is clipped, as a real filmstrip is.
Regenerates on resize (debounced 220ms, since each pass is a run of
decoder seeks). The hover card's height now derives from the source
aspect ratio rather than an assumed 16:9.
…down

A fixed 30-minute hold was wrong in both directions: it could cut off
someone carefully trimming a long recording, and it left an abandoned
session unpublished for half an hour. Neither is a real property of
'is the user still editing?'.

Now whatever surface represents active editing renews a short lease:
POST /:token/editing every 30s, server holds 120s past the last renewal.
Editing takes exactly as long as it takes, and closing the window or
quitting publishes the timelapse about two minutes later. An absolute
ceiling measured from the stop (EDIT_HOLD_MAX_MINUTES) bounds an editor
left open overnight so a program is never waiting forever.

- shared: EDIT_LEASE_SECONDS / EDIT_HEARTBEAT_SECONDS /
  EDIT_HOLD_MAX_MINUTES replace EDIT_HOLD_MINUTES
- server: POST /:token/editing renews (and renews through a term that
  lapsed seconds ago, so a network stall doesn't end an edit — the
  status guard is what keeps that from resurrecting a published
  session); stop opens one lease term; the expiry job also fires on the
  ceiling
- react SDK: useEditLease hook, held by BOTH the editor and the
  SessionDetail review panel — sitting on the review panel is still
  deciding, and must not publish underneath the reader
- countdown UI removed everywhere; the copy now just says nothing is
  published until you save, and that closing Lookout publishes as
  recorded
- lease tests: renewal extends, lapsed-but-unprocessed renews, published
  sessions report held:false and stay published, ceiling stops renewal
The 'preparing' poll ran setPreparing({ expectedUnits }) every 1.5s. Same
value, but a fresh object literal each time — so the state identity
changed, the progress effect that depends on it tore down and re-ran, and
its `startedAt = Date.now()` anchor reset. The ring walked 0 -> 12% -> 0
on a 1.5s cycle.

- the poll now stores a NUMBER, so an unchanged unit count is an identity
  no-op and React bails out of the re-render entirely
- the start time is anchored in a ref for the whole preparing spell, so
  even a genuine change in the count can't rewind it
- progress is clamped monotonic on top of that
- extracted estimateBuildProgress() as a pure function of elapsed time,
  which is what makes rewinding structurally impossible, and covered it
  with tests including the exact regression
- same anchoring applied to the review panel's ring, which had the latent
  version of the bug
- removes the hover-to-preview card and its second video decoder: the
  filmstrip already shows the footage, and a card chasing the cursor was
  noise on top of it (also frees a decoder and the coalescing machinery)
- playhead is now a rounded tag above the ruler with a stem through the
  strip, per the reference. Rendered as a sibling of both lanes rather
  than inside the strip, so the head isn't clipped by its overflow, and
  it's grabbable: dragging the head scrubs
- ruler gains real ticks. rulerStep() picks the labelling interval from
  the track width so labels never crowd and are always round numbers
  (0/5/10, never 0/7/14); rulerTicks() places a major on each step and a
  minor between, comparing on the rounded quotient so accumulated
  half-step float drift can't miss a major
- filmstrip already regenerated on resize via ResizeObserver; the ruler
  now recomputes from the same measurement, so both track the window

11 new tests cover step selection, spacing guarantees, float drift, and
empty input.
anscg and others added 13 commits July 27, 2026 14:17
PageContainer defaults to 640px, so the inline editor got ~590px of
usable width — less than the 800 the recorder itself uses, and only about
six filmstrip frames to cut against. Widened to 960, roughly matching the
desktop editor window. The timeline is a precision surface; every 100px
of width is another whole frame.
Embedders put the recorder in columns and cards of arbitrary width, and
inline meant the timeline inherited that width — a precision tool you
can't be precise with. The overlay takes the viewport instead, and the
detail view keeps rendering behind it rather than being replaced.

New `Overlay` primitive, portalled to document.body. That matters for an
SDK dropped into pages we don't control: `position: fixed` resolves
against the nearest transformed or filtered ancestor, so an embedder with
a transform anywhere up the tree would otherwise get a modal pinned
inside their card. It also locks body scroll and moves focus into the
dialog. StopChoiceModal now uses it too, since it had the same exposure.

The editor overlay has no dismiss: leaving without deciding would strand
an unpublished session, so Save is the exit — and an abandoned tab is
covered by the edit lease.
`<LookoutProvider accentColor="#16a34a">` recolours primary buttons, focus
rings, and the compile progress ring — everywhere the UI says "this is the
main action". `accentTextColor` covers the case where a light brand colour
makes white labels unreadable. `setAccentColor()` is exported for the
surfaces used without a provider (SessionDetail, TimelapseEditor).

Three decisions worth stating:

- Semantic colours are deliberately excluded. Success green, warning amber
  and the red marking removed footage carry meaning, not brand; a green
  "this will be deleted" would be worse than an off-brand one.
- Applied to the document root rather than a wrapper, because the stop
  dialog and the editor portal to document.body and a scoped subtree
  wouldn't reach them. The provider restores the previous value on
  unmount so a temporarily-mounted Lookout doesn't leak its accent.
- The hover shade is derived via color-mix so embedders supply one colour,
  with a literal fallback declared first — an unsupported color-mix is
  dropped at parse time and the literal survives, rather than the hover
  background resolving to nothing.

The playground gains an accent picker so this is checkable against the
editor and both dialogs.
The tray's SwiftPM .build directory was tracked, including a 64MB index
database, and a .env.bak sat alongside it. Both are now purged from this
branch's history; these rules stop them coming back.
Work in progress that was sitting uncommitted: the compile_progress column
plus the gallery/editor surfaces that read it, and the clock-style tray title.
The fixture pinned T0 to 2026-07-01 — a future date when it was written, so it
passed CI happily until the calendar caught up. The edit hold's ceiling is
measured from stoppedAt (EDIT_HOLD_MAX_MINUTES after the stop), so once that
date was more than two hours in the past every lease test failed with
held:false for reasons that had nothing to do with leases.
The editor used to wait on a full-quality 1080p CRF18 build, and then the
published video was a byte-copy of it — so making the preview cheaper would
have made the published timelapse worse. Split the two:

  preview   720p CRF30 superfast, built only to open the editor, deleted
            on publish. 431 -> 83 ms/unit measured, at half the bytes.
  published re-encoded at full quality from the capture units, with cuts
            applied by not encoding the removed units — so cutting half a
            session makes publishing cheaper, not dearer.

superfast rather than ultrafast deliberately: 15% slower for half the size,
and the preview is uploaded and then streamed back by the editor, so its size
is part of the latency this exists to reduce. ultrafast's output was actually
larger than the 1080p publish tier.

Guards, because a preview must never reach a viewer:
- original_is_preview marks such a build; a null/false value means
  publish-grade, so legacy sessions and sessions that never entered the edit
  flow keep the lossless-copy path untouched.
- A preview build never publishes. If the hold lapses mid-build it hands over
  to the publish path rather than shipping a low-res video.
- If the units are gone (retention purge, R2 outage), it publishes the preview
  with a loud error rather than stranding the recording.
- Sampled units are now load-bearing until publish; cleanup keeps them.
Two changes to how clips are configured.

Cadence is now 10s (6 frames/min). Every knob that used to be denominated in
frames is derived from it — the per-frame byte budget, the native encoder
bitrate, the stall cap — so per-frame QUALITY is held constant when the cadence
moves, in either direction. That mattered here: native encoders are handed real
presentation timestamps, so their bitrate buys bytes per second of MEDIA time,
and a fixed 800kbps would have inflated every clip toward the server's 8MB cap
as the interval grew. The formula reproduces the hand-tuned 800kbps at the old
4s cadence exactly, which is what makes it trustworthy elsewhere.

Clips are also the default now: clips_enabled defaults true and a program opts
OUT with clips:false. Existing rows are deliberately not backfilled — a
session's capture character is immutable, so in-flight sessions keep the mode
they started with. Clients that can't record clips are unaffected either way;
they keep uploading JPEGs to the same session, which stays fully valid.

Smoothness scales with the cadence, so this is 6 distinct images per output
second rather than 15. Bandwidth falls with it: ~1.1 MB/min for a typical
screen, under half what 15/min cost.
An out-of-envelope capturedAt returned 400 from upload-url, which meant a
client whose clock was more than five minutes off never received a presigned
URL and uploaded NOTHING for the entire session. A skewed clock is common,
invisible to the user, and none of their doing.

The server now adopts its own clock for those captures instead. That is
strictly safer than accepting the claim — server time is unforgeable, so a
hostile client gains nothing by sending a wild timestamp; it just gets stamped
with the moment the server saw it. It costs precision, not the recording: the
capture carries upload latency, so credit is measured a little late.

Adoption is for clocks, not for requests. Only the two envelope failures are
absorbed; non-monotonic and pre-session timestamps are still refused, and the
substituted value is re-validated against both, so replay protection survives.

The response reports capturedAtAdopted so a client can correct itself, and
ClockOffset (shared) does exactly that: it learns the offset from the server's
own timestamps, bracketing each sample so the round trip is charged to latency
rather than to the offset. A healthy clock stays inside a deadband and its
stamps pass through untouched.

Also here, both small:
- POST /compile returned 202 for any compiling session, which shadowed the
  branch that drops the edit hold. A user who declined editing mid-preview got
  a cheerful 202 while their session stayed held, then waited for the very
  preview they had just declined. The discriminator is whether an original
  exists: with one, the in-flight job is the publish; without, it is the
  preview build and the request means 'publish as recorded'.
- R2_ENDPOINT override so the stack can run against a local S3-compatible
  endpoint. Unset in production, where the account-derived host is used.
Four compounding failures on a slow uplink, all in the browser client. The
desktop loop already had the right shape; this brings the web one in line.

1. No timeout anywhere. fetch has no default, so a half-open socket parked the
   capture loop indefinitely with no error to retry on. Every step now has a
   30s deadline, which fits inside the 120s presigned-URL expiry with room for
   the retry budget.
2. The upload blocked the loop. Each second of latency was a second the
   recorder wasn't cutting on schedule, so clips stretched to cover minutes
   each — and a clip renders as ONE second of video however long it took to
   record, so that footage was genuinely lost. Uploads now run concurrently,
   strictly one in flight, settled at the next tick to keep them ordered.
3. The in-flight clip grew unbounded. A five-minute stall produced a clip that
   blew the 8MB cap and was refused on arrival, costing the whole window. Now
   capped at 3x nominal; the tail of a stalled window is dropped instead.
4. The oversize path halved the encoder bitrate permanently, so one network
   incident left the rest of the session soft — a user on bad wifi ended up
   with a worse timelapse than one on no wifi. The backoff no longer fires for
   a clip that was merely stalled.

And the two fallbacks desktop had that web didn't:
- A failed CLIP upload is retried as a JPEG reusing the clip's own
  capturedAtMs, so the minute still credits. Previously the whole minute was
  lost.
- Clips latch off after 3 consecutive failures, or immediately on a typed
  ClipFormatRejectedError. That error's message used to claim 'falling back to
  JPEG captures' while nothing did.
Two real bugs in the Media Foundation path, both found by reading it rather
than running it — this is the one platform CI can only type-check:

- Sample duration was pinned at 3000ms, a cadence the app no longer uses, so
  every sample claimed a span that disagreed with its own timestamps. Now
  derived from the real frame interval.
- MFStartup leaked a refcount on every failed init. Invisible when init
  succeeds (finish() balances it), unbounded on a machine whose encoder always
  fails, because the loop retried init once per frame for the whole session.
  Now RAII-guarded, with ownership passed to the encoder on success.

Media Foundation also gets a two-attempt media-type negotiation: declare the
real sub-1fps cadence first, so the bitrate and the frame-rate hint agree about
what a second means, and fall back to the 1fps hint this shipped with (scaling
the bitrate to match) if the MFT refuses fractional rates. Only if both fail
does the interval fall back to a JPEG.

And a failure latch: after 3 consecutive encoder failures, clips turn off for
the run. Each failure was already survivable — the interval falls back to a
JPEG — but a machine where the encoder can never initialise was paying the full
cost of constructing and tearing down an OS encoder several times a minute for
hours. Any clip that finalises resets the count, so a transient hiccup (a
display mode change, a busy GPU) never disables clips.

Windows per-frame output remains unmeasured on real hardware; if clips come
back soft or oversize, that negotiation is where to look first.
cargo test/check ran the debug profile. The clip encoders are unsafe FFI
against VideoToolbox, Media Foundation and GStreamer — exactly the code whose
behaviour differs under optimisation, and this project has already been bitten
once by a misalignment crash that only appeared in a release build. Testing a
debug binary proves the wrong artifact. The Windows job is the only one that
compiles the Media Foundation encoder at all, so it should compile it the way
users get it.

Also adds the worker job (ffmpeg segment pipeline) and the shared package's
unit tests, which now cover clock-offset estimation.
…resilience

Skips 0.3.6, which is taken by the unreleased crash-handler branch.

Workspace packages move 0.3.3 -> 0.3.7 and the desktop app 0.3.5 -> 0.3.7, so
both tracks read the same number from here on. Also adds the shared package's
test script (its units are new in this branch) and corrects the SDK doc's
stale v0.1.0 header.
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Aug 9, 2026
Anson Chung and others added 9 commits August 9, 2026 13:00
During the edit hold the original holds every minute the user is about to cut
out, and it stays readable until the publish deletes it. Its key was
`timelapses/<sessionId>/original.mp4` — and sessionId is public, appearing in
the /api/media/:sessionId/... URLs handed out with any shared timelapse. So
anyone with a share link could reconstruct the original's URL and, if the
bucket is readable at all (R2_PUBLIC_DOMAIN fronts it publicly in the
documented setup), fetch the cut footage — straight past the token gate that
/units presigns behind.

Now `original-<128 random bits>.mp4`. That holds whether or not the bucket is
public, which is the property worth having: it doesn't depend on an ACL staying
correct. The published video keeps its predictable key, since being fetchable
is the point of it.

A recompile reuses the session's existing key rather than minting a new one, so
the old object is overwritten instead of orphaned. Nothing rebuilds this key
from the session id — every reader takes it from
sessions.original_video_r2_key, which is what made the change contained.
The Desktop Rust job has been failing since clips landed — every test that
constructs a real ClipRecorder dies with 'no H.264 encoder element available'.
It installs libgstreamer1.0-dev and libgstreamer-plugins-base1.0-dev, which are
DEV headers; the elements the encoder actually builds live in four separate
runtime packages: videoconvert in -base, mp4mux in -good, h264parse in -bad,
x264enc in -ugly. None were present, so ENCODER_CANDIDATES matched nothing.

Not caused by the switch to --release; the run on d938b7e failed identically.

Worth having rather than skipping the tests, because this job is the only place
the Linux encoder path is executed at all — macOS runs it on a dev machine and
Windows is check-only. With the plugins present it goes from compiled to
tested.
bundle.linux was empty, so the .deb and .rpm declared nothing at all — and the
Linux build needs GStreamer elements spread across several packages. The worst
of it isn't clips: pipewiresrc is how the app captures the screen at all
(pipewire.rs), and it ships in gstreamer1.0-pipewire. A user could install the
package and have capture simply not work.

Depends covers what the app cannot run without — pipewiresrc, videoconvert —
plus mp4mux and h264parse, since clips are the default capture mode now.

The H.264 encoder is Recommends rather than Depends on purpose. -ugly carries
GPL x264, and gstreamer1-plugins-ugly isn't in Fedora proper (RPMFusion), so a
hard dependency would either impose that licence on every install or make the
rpm uninstallable on a stock Fedora. apt and dnf install Recommends by default,
so the ordinary user still gets an encoder; anyone excluding it gets a working
app whose clips fall back to one JPEG a minute, which the failure latch now
handles quietly.

This is the same gap that had CI failing: dev headers were installed, runtime
plugins were not.
Found by stress-testing the encode cycle: RSS grew 4,894 KB for every clip
recorded. One clip a minute means ~3.5 GB over a 12-hour session.

Every Cocoa object the encoder builds — the settings dictionaries, the NSNumbers
in them, the writer, the input, the pixel-buffer adaptor, the CVPixelBuffers —
is autoreleased. The capture loop calls this from a tokio worker thread, and
unlike the main run loop a tokio thread never drains an autorelease pool, so
none of it was ever freed. Nothing was wrong with the retain/release balance;
there was simply no pool to drain into.

All three entry points now run inside an explicit autoreleasepool. Measured
over 200 cycles (~3.3 hours of recording): 4,894 KB/cycle -> 2 KB/cycle, and
baseline process RSS fell from 84 MB to 55 MB. The discard path went from 2,439
to 51 KB/cycle and is sub-linear, so that remainder is warm-up, not
accumulation.

Adds the tests that found it, plus the cost measurement:
- encode_cycle_does_not_leak / discard_path_does_not_leak — ignored stress
  tests, LEAK_CYCLES tunable, budget scales with the run.
- clip_temp_files_are_always_removed — runs in CI; covers finish, discard, and
  the finish-with-no-frames error path.
- encode_cost_at_1080p — 96 ms/clip worst case (13.7 ms/frame, 2 MB/clip),
  i.e. 0.16% of one core at a clip a minute.

The leak tests stay manual: they assert on RSS, which is too environment-
dependent to gate CI on. The temp-file test is the one that runs every push.
Opening Filtered Apps was doing all its work synchronously inside an `async fn`
command. `async` alone bought nothing: the body never yields, so it occupied a
tokio worker for its whole duration — and the capture loop runs on those same
workers, so a slow app scan could delay a capture tick, not just the page.

Three things, in order of how much they cost:

- The command body now runs on the blocking pool via spawn_blocking, so it
  cannot hold a capture worker regardless of how slow the machine's filesystem
  or window enumeration is.
- The installed-app cache is pre-warmed in a background thread, DEFERRED five
  seconds. The scan is disk-bound, and launch is the most I/O-contended moment
  in the process's life (the webview is loading its own assets), so warming it
  immediately would trade a faster Settings page for a slower app open. Five
  seconds is long before anyone navigates there and after launch I/O settles.
  A failed spawn is ignored — the cache then fills lazily, exactly as before.
- The Windows Start Menu walk used `entry.path().is_dir()`, which stats every
  entry a second time; the directory enumeration already returned the
  attributes. `entry.file_type()` uses them. The Start Menu has hundreds of
  entries and every stat goes through whatever filter drivers and AV hooks are
  installed. `file_type()` doesn't follow symlinks where `is_dir()` did, so a
  directory junction still falls back to the stat rather than silently stopping
  being traversed.

Note what is NOT fixed: `running_apps()` enumerates windows on every open and
is uncached, by design — it has to be live for a running app to be listed. It is
now merely off the capture workers rather than cheaper.

Windows behaviour is unverified by me; the cross-platform check job compiles it.
clip_temp_files_are_always_removed compared a COUNT of lookout-clip-* files in
the OS temp dir before and after. That directory is shared by the whole
process, cargo runs tests in parallel, and encodes_frames_into_playable_mp4
stages two files of its own there for ffprobe — so an interleaving where the
count rises by one across the window is not just possible, it is exactly the
observed failure ("left: 1, right: 0").

My bug, not a product one: no path leaks a clip container. finish() and
discard() both remove the file unconditionally, including when finish errors on
a zero-frame clip, which is the case the test exists to cover.

Both tests now hold a TEMP_DIR mutex, so the counting window can't overlap a
creator. Poisoning is ignored deliberately — one test panicking should surface
as that test's failure, not cascade. The assertion also reports leaked
FILENAMES rather than a count, so if it ever fails for real the name says which
recorder left it.

Verified with 25 consecutive runs at --test-threads=8: no failures.
… temp dir

My previous attempt at this was still wrong. Locking two tests wasn't enough —
empty_clip_errors and resized_frames_are_normalized build recorders too, so
their in-flight containers still landed in the counting window and got reported
as leaks. Adding them to the lock would have worked, but the design was the
problem: a test that inspects a directory shared by the whole process is
coupled to every other test in it.

It now asks each recorder for its own path and asserts that path is gone. No
shared state, no lock, correct under any amount of parallelism, and the failure
message points at one specific recorder. 30 full-suite runs at 16 threads: no
failures.

Also fixes a real orphan the investigation turned up: if platform::Encoder::new
FAILS, the container may already exist — GStreamer's filesink opens it the
moment the pipeline goes Playing — and there is no ClipRecorder yet whose
finish()/discard() would remove it. That matters more now the capture loop
retries init before latching clips off: a machine with a broken encoder would
drip one orphan into the temp dir per attempt. ClipRecorder::new now removes the
path on the error path.
Clips at 6 frames/minute, on by default, plus stop-time editing.

Capture:
- Cadence 10s (6 fpm), server-authoritative. Per-frame quality is now derived
  from the cadence, so changing the rate holds frame quality constant.
- clips_enabled defaults TRUE; programs opt OUT with `clips: false`.
- Web uploads run off the capture loop's critical path, clips are bounded
  against a stalled upload, a failed clip upload retries as a JPEG, and clips
  latch off after repeated failures.
- A client clock outside the trust envelope is adopted as server time instead
  of failing the upload; clients learn their offset and self-correct.

Compile:
- Two tiers: a throwaway 720p preview opens the editor (~5x faster to build),
  and the published video is re-encoded at full quality from the capture units
  with cuts applied, so cutting makes publishing cheaper.

Fixes worth calling out:
- macOS encoder leaked ~4.9 MB per clip (no autorelease pool on the tokio
  worker) — ~3.5 GB over a 12-hour session. Now 2 KB/cycle.
- Windows Media Foundation: sample duration was pinned to an unused cadence,
  and MFStartup leaked a refcount per failed init.
- The uncut original moved to an unguessable R2 key; it was derivable from the
  public session id.
- Linux runtime dependencies are declared for the first time (pipewiresrc is
  how capture works at all on Linux).
- CI builds and tests the desktop client in release, and now installs the
  GStreamer plugins the Linux encoder needs — that job had been failing.

Migrations: 0020 compile_progress, 0021 original_is_preview, 0022
clips_default_on. 0022 changes behaviour for every new session.

Unverified at merge time: Windows runtime behaviour (compile-checked only) and
whether the new .deb/.rpm dependency names resolve on a clean distro.
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.7.0 to 6.0.5.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.5/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.5
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot changed the title build(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 6.0.5 in /clients/desktop chore(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 6.0.5 in /clients/desktop Aug 9, 2026
@dependabot
dependabot Bot force-pushed the dependabot/npm_and_yarn/clients/desktop/vitejs/plugin-react-6.0.5 branch from 02f0f41 to 38c634a Compare August 9, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants