Skip to content

chore(deps): bump shell-quote and concurrently - #159

Open
dependabot[bot] wants to merge 94 commits into
mainfrom
dependabot/npm_and_yarn/multi-bb2862ba2e
Open

chore(deps): bump shell-quote and concurrently#159
dependabot[bot] wants to merge 94 commits into
mainfrom
dependabot/npm_and_yarn/multi-bb2862ba2e

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Jul 25, 2026

Copy link
Copy Markdown
Contributor

Bumps shell-quote to 1.9.0 and updates ancestor dependency concurrently. These dependencies need to be updated together.

Updates shell-quote from 1.8.3 to 1.9.0

Changelog

Sourced from shell-quote's changelog.

v1.9.0 - 2026-06-24

Commits

  • [New] add types dca6e21
  • [Dev Deps] update eslint 9aa9e8f
  • [Fix] parse: finalize tokens in linear time (GHSA-395f-4hp3-45gv) 7ff5488
  • [actions] update workflows 75e8497
  • [actions] Windows + node 4/6/7: pin eslint to 9 before install, since npm 2/3 cannot stage eslint 10@types/esrecurse 3fb739d
  • [actions] retry npm install on Windows to survive npm 2/3 staging-rename flake abe0163
  • [actions] Windows + node 5/7: install deps with a modern node b4bafa2
  • [Fix] quote: escape leading ~ to prevent shell tilde-expansion 7a76c1a
  • [Dev Deps] update auto-changelog, tape 7184b44
  • [Dev Deps] apparently jackspeak is no longer in the graph 9ba368a

v1.8.4 - 2026-05-22

Commits

  • [Fix] quote: validate object-token shapes 4378a6e
  • [Dev Deps] update @ljharb/eslint-config, auto-changelog, eslint, npmignore 22ebec0
  • [Tests] increase coverage 9f3caa3
  • [readme] replace runkit CI badge with shields.io check-runs badge 3344a04
  • [Dev Deps] update @ljharb/eslint-config 699c511
Commits
  • db09fc7 v1.9.0
  • 7ff5488 [Fix] parse: finalize tokens in linear time (GHSA-395f-4hp3-45gv)
  • b4bafa2 [actions] Windows + node 5/7: install deps with a modern node
  • 3fb739d [actions] Windows + node 4/6/7: pin eslint to 9 before install, since npm 2/3...
  • abe0163 [actions] retry npm install on Windows to survive npm 2/3 staging-rename flake
  • 7a76c1a [Fix] quote: escape leading ~ to prevent shell tilde-expansion
  • 75e8497 [actions] update workflows
  • dca6e21 [New] add types
  • 9aa9e8f [Dev Deps] update eslint
  • 9ba368a [Dev Deps] apparently jackspeak is no longer in the graph
  • Additional commits viewable in compare view

Updates concurrently from 9.2.1 to 9.2.4

Release notes

Sourced from concurrently's releases.

v9.2.4

  • upgrade shell-quote to 1.9.0 - #597, #600

Full Changelog: open-cli-tools/concurrently@v9.2.3...v9.2.4

v9.2.3

v9.2.2

Not published to npm.

Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for concurrently since your current version.


anscg and others added 16 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.
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Jul 25, 2026
@socket-security

socket-security Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​concurrently@​9.2.1 ⏵ 9.2.49910010093 +2100

View full report

anscg and others added 8 commits July 26, 2026 17:05
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 dependabot Bot changed the title build(deps): bump shell-quote and concurrently chore(deps): bump shell-quote and concurrently Aug 9, 2026
@dependabot
dependabot Bot force-pushed the dependabot/npm_and_yarn/multi-bb2862ba2e branch from bf9b4cd to 6a270ea Compare August 9, 2026 06:02
Anson Chung and others added 26 commits August 9, 2026 17:45
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
dependabot Bot force-pushed the dependabot/npm_and_yarn/multi-bb2862ba2e branch from 6a270ea to 58ea989 Compare August 13, 2026 18:18
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