Skip to content

Add Linux CLI support#1908

Merged
richiemcilroy merged 3 commits into
mainfrom
linux-cli
Jun 10, 2026
Merged

Add Linux CLI support#1908
richiemcilroy merged 3 commits into
mainfrom
linux-cli

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jun 10, 2026

Copy link
Copy Markdown
Member

Adds Linux recording support, Linux release publishing, and Linux download/install paths for the Cap CLI.

Greptile Summary

This PR adds end-to-end Linux support: a new Rust screen-capture backend (X11/x11grab and Wayland via XDG portal + PipeWire), Linux camera capture, a substantially expanded shell install script with AppImage/DEB/RPM paths, updated web download routing and release metadata for Linux, and a new Linux CI publish job.

  • Rust recording backend: linux.rs introduces X11 capture (FFmpeg x11grab) and a Wayland capture path (XDG Desktop Portal + PipeWire) with runtime fallback from Wayland to X11; cursor.rs refactors the cursor thread from async to std::thread with an mpsc wakeup channel and adds X11 XFixes cursor capture.
  • Web/download layer: install-cli.sh gains Linux-specific AppImage extraction, DEB/RPM installation, arch detection, and XDG_DATA_HOME awareness; download routes and the versions page are updated for linux, linux-appimage, linux-deb, and linux-rpm platform slugs.
  • CI: publish.yml adds an ubuntu-24.04 build target with Apple signing steps correctly skipped for Linux runners.

Confidence Score: 5/5

Safe to merge — Linux recording backend, install script, and web download routing are well-structured with correct platform guards and no regressions on macOS/Windows paths.

Both findings are cosmetic/quality concerns that do not affect recording correctness. The premultiplied alpha issue only affects cursor themes with anti-aliased edges, and the FrameScaler mismatch causes a single extra allocation on the first x11grab frame.

crates/recording/src/cursor.rs and crates/recording/src/sources/screen_capture/linux.rs are new Linux-only code paths that have not yet been battle-tested in production.

Important Files Changed

Filename Overview
crates/recording/src/cursor.rs Refactors cursor recording to a std::thread with mpsc wakeup channel for cancellation; adds Linux X11 cursor capture via XFixes with premultiplied-alpha issue in ARGB→RGBA conversion.
crates/recording/src/sources/screen_capture/linux.rs New Linux screen capture implementation supporting both X11 (via x11grab/FFmpeg) and Wayland (via XDG portal + PipeWire); FrameScaler initialized with wrong source pixel format (BGRA vs BGRZ), causing one-time scaler reconstruction on first frame.
apps/web/app/install-cli.sh/route.ts Extensive Linux install support added: AppImage extraction, DEB/RPM package install, XDG data dir handling, arch validation, and pactl-based system audio discovery. Trap/cleanup logic and variable scoping look correct.
apps/web/app/api/download/route.ts Adds Linux platform detection using Sec-CH-UA-Platform Client Hints header with UA-string fallback; Android UA strings correctly excluded from Linux detection.
apps/web/components/pages/DownloadPage.tsx Linux download options correctly gated: AppImage link shown to non-Linux users, DEB/RPM links only shown to Linux users; prior unconditional rendering issue is resolved.
.github/workflows/publish.yml Adds Linux (ubuntu-24.04) CLI build target; Apple signing/notarization steps correctly gated behind runner.os == 'macOS' to avoid leaking credentials or failing on Linux runners.
crates/recording/src/instant_recording.rs Camera-only and screen recording paths split into cfg-gated blocks for Linux vs macOS/Windows; max_fps now configurable via builder method.
apps/web/utils/releases.ts Adds linux-appimage/deb/rpm keys to ReleaseDownloads; JSON parsing refactored to loop over a typed key list with string validation.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/recording/src/cursor.rs:494-500
**XFixes cursor data uses premultiplied alpha**

XFixes `GetCursorImage` returns ARGB pixel values with premultiplied alpha (per the XFixes protocol spec). The extraction here treats them as straight-alpha RGBA. For pixels with partial transparency (e.g., anti-aliased cursor edges in themed cursors), the RGB components would be left at their premultiplied values, causing the edges to look darker than they should when rendered over the recording. Cursors with only fully-opaque or fully-transparent pixels (the common case for system cursors) are unaffected. To fix, un-premultiply before storing: divide each color channel by `(alpha / 255.0)` when `alpha > 0`.

### Issue 2 of 2
crates/recording/src/sources/screen_capture/linux.rs:882-890
**`FrameScaler` initialized with wrong source pixel format**

`FrameScaler::new` is called with `video_info.pixel_format` (which is `BGRA` from `X11Capture::pixel_format()`) as the source format, but `x11grab` actually produces `BGRZ` frames. On the first packet, `scaler.matches(frame)` returns `false` (BGRA ≠ BGRZ), causing the scaler to be recreated as BGRZ→BGRA. After that first reconstruction the scaler is stable and subsequent frames are processed correctly, so this is only a one-time extra allocation at startup. Initializing with `ffmpeg::format::Pixel::BGRZ` would avoid the reconstruction.

Reviews (3): Last reviewed commit: "fix: resolve linux cli follow-ups" | Re-trigger Greptile

@richiemcilroy richiemcilroy marked this pull request as ready for review June 10, 2026 16:09
Comment thread apps/web/app/api/download/route.ts Outdated
} else if (userAgent.includes("Mac")) {
if (userAgent.includes("Intel")) {
} else if (clientPlatform.includes("macos") || userAgent.includes("mac")) {
if (userAgent.includes("intel")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Intel Macs don’t always include intel in the UA (I’ve seen x86_64/amd64 instead), so this can mis-route to Apple Silicon.

Suggested change
if (userAgent.includes("intel")) {
if (userAgent.includes("intel") || userAgent.includes("x86_64") || userAgent.includes("amd64")) {
platform = "apple-intel";
} else {
platform = "apple-silicon";
}

Comment thread apps/web/app/install-cli.sh/route.ts Outdated
if ! find_cap_app; then
install_cap_desktop
echo "Downloading Cap Desktop AppImage..."
curl -fL "https://cap.so/download/linux-appimage" -o "$APPIMAGE_PATH"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since this is an installer script, it’s worth making the downloads a bit more robust against transient network failures (and harder to accidentally fetch over a downgraded transport).

Suggested change
curl -fL "https://cap.so/download/linux-appimage" -o "$APPIMAGE_PATH"
curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-appimage" -o "$APPIMAGE_PATH"

let y_stride = format.stride.max(width);
let chroma_width = width / 2;
let chroma_height = height / 2;
let chroma_stride = y_stride / 2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

chroma_stride = y_stride / 2 assumes U/V stride is exactly half the luma stride. If the driver pads format.stride oddly (or it’s not describing the luma plane stride the way we expect), this can produce incorrect plane offsets and out-of-bounds slices. Might be worth either validating y_stride % 2 == 0 (and erroring early) or plumbing per-plane strides from the capture format so we don’t have to guess here.

Comment on lines +157 to +180
<a
href="/download/linux-deb"
onClick={() =>
trackDownloadClick(
"other_option_linux_deb",
"/download/linux-deb",
)
}
className="text-sm transition-all text-gray-10 hover:underline"
>
Debian/Ubuntu
</a>
<a
href="/download/linux-rpm"
onClick={() =>
trackDownloadClick(
"other_option_linux_rpm",
"/download/linux-rpm",
)
}
className="text-sm transition-all text-gray-10 hover:underline"
>
Fedora/RHEL
</a>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Linux DEB/RPM links shown unconditionally

The "Debian/Ubuntu" and "Fedora/RHEL" links have no platform guard, so they are rendered for every visitor — macOS users see three Linux format links alongside the Apple Silicon/Intel alternatives, and Windows users also see all three. The "Linux AppImage" link correctly uses platform !== "linux" to hide itself from Linux users who already have it as the primary download, but the DEB and RPM links lack any guard. Consider wrapping them the same way the AppImage link and other alternatives are gated so each platform sees a relevant short list.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/components/pages/DownloadPage.tsx
Line: 157-180

Comment:
**Linux DEB/RPM links shown unconditionally**

The "Debian/Ubuntu" and "Fedora/RHEL" links have no platform guard, so they are rendered for every visitor — macOS users see three Linux format links alongside the Apple Silicon/Intel alternatives, and Windows users also see all three. The "Linux AppImage" link correctly uses `platform !== "linux"` to hide itself from Linux users who already have it as the primary download, but the DEB and RPM links lack any guard. Consider wrapping them the same way the AppImage link and other alternatives are gated so each platform sees a relevant short list.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread crates/recording/src/cursor.rs Outdated
Comment on lines +259 to +266
let mut last_cursor_id: Option<String> = None;

loop {
let sleep = tokio::time::sleep(Duration::from_millis(16));
let Either::Right(_) =
futures::future::select(pin!(stop_token_child.cancelled()), pin!(sleep)).await
else {
if stop_token_child.is_cancelled() {
break;
};
}

std::thread::sleep(Duration::from_millis(16));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Stop latency up to 16 ms after cancellation

The cancellation token is checked at the top of the loop, before std::thread::sleep. Once stop() is called, the thread does not wake early — it finishes the current 16 ms sleep, then checks is_cancelled() on the next iteration. The previous async implementation using futures::future::select would wake immediately on cancellation. For a cursor-recording thread this delay at teardown is harmless, but since CursorActor::stop joins the thread before returning, callers will observe up to a 16 ms block. If prompt teardown becomes important, consider parking_lot::Condvar or an mpsc channel to interrupt the sleep.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 259-266

Comment:
**Stop latency up to 16 ms after cancellation**

The cancellation token is checked at the top of the loop, before `std::thread::sleep`. Once `stop()` is called, the thread does not wake early — it finishes the current 16 ms sleep, then checks `is_cancelled()` on the next iteration. The previous async implementation using `futures::future::select` would wake immediately on cancellation. For a cursor-recording thread this delay at teardown is harmless, but since `CursorActor::stop` joins the thread before returning, callers will observe up to a 16 ms block. If prompt teardown becomes important, consider `parking_lot::Condvar` or an `mpsc` channel to interrupt the sleep.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +96 to +120
for name in cap-cli cap-cli-x86_64-unknown-linux-gnu cap-cli-aarch64-unknown-linux-gnu; do
candidate="$dir/$name"
if [ -x "$candidate" ]; then
CLI_TARGET="$candidate"
return 0
fi
done
done

candidate="$(find "$root" -type f \( -name "cap-cli" -o -name "cap-cli-*linux*" \) -perm -111 2>/dev/null | head -n 1 || true)"
if [ -n "$candidate" ]; then
CLI_TARGET="$candidate"
return 0
fi

return 1
}

prepare_appimage_cli() {
if [ -z "$APPIMAGE_PATH" ] || [ ! -f "$APPIMAGE_PATH" ]; then
return 1
fi

if [ -z "$APPDIR_PATH" ]; then
APPDIR_PATH="$(linux_app_data_dir)/Cap.AppDir"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 prepare_appimage_cli leaves the EXIT trap active on success

When prepare_appimage_cli succeeds it zeroes out TMP_ROOT and APP_TMP, but it never resets the trap (trap - EXIT HUP INT TERM). The behavior is idempotent — cleanup_desktop_install is a no-op when both variables are empty — but the asymmetry with install_cap_desktop_macos (which always pairs trap … EXIT … with a matching trap -) makes future maintenance harder. Adding trap - EXIT HUP INT TERM at the successful-return point would align with the pattern used everywhere else in the script.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/install-cli.sh/route.ts
Line: 96-120

Comment:
**`prepare_appimage_cli` leaves the EXIT trap active on success**

When `prepare_appimage_cli` succeeds it zeroes out `TMP_ROOT` and `APP_TMP`, but it never resets the trap (`trap - EXIT HUP INT TERM`). The behavior is idempotent — `cleanup_desktop_install` is a no-op when both variables are empty — but the asymmetry with `install_cap_desktop_macos` (which always pairs `trap … EXIT …` with a matching `trap -`) makes future maintenance harder. Adding `trap - EXIT HUP INT TERM` at the successful-return point would align with the pattern used everywhere else in the script.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jun 10, 2026
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai please re-review the pr

Comment thread apps/web/app/install-cli.sh/route.ts Outdated
trap cleanup_desktop_install EXIT HUP INT TERM

echo "Downloading Cap Desktop Debian package..."
curl -fL "https://cap.so/download/linux-deb" -o "$DEB_PATH"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice touch hardening the AppImage download curl invocation; worth using the same flags for the DEB download so it’s equally robust.

Suggested change
curl -fL "https://cap.so/download/linux-deb" -o "$DEB_PATH"
curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-deb" -o "$DEB_PATH"

Comment thread apps/web/app/install-cli.sh/route.ts Outdated
trap cleanup_desktop_install EXIT HUP INT TERM

echo "Downloading Cap Desktop RPM package..."
curl -fL "https://cap.so/download/linux-rpm" -o "$RPM_PATH"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same for RPM download — matching the AppImage curl flags makes this more resilient to transient failures and avoids protocol downgrade surprises.

Suggested change
curl -fL "https://cap.so/download/linux-rpm" -o "$RPM_PATH"
curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-rpm" -o "$RPM_PATH"

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jun 10, 2026
Comment on lines +228 to +232
return CursorActor {
stop: None,
thread: None,
rx: rx.shared(),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0 Missing stop_wakeup field — Linux compile error

The CursorActor struct now has four fields (stop, stop_wakeup, thread, rx), but this Wayland early-return initializer only sets stop, thread, and rx — it omits stop_wakeup: None. Rust requires all fields to be listed in a struct literal, so the Linux build will fail with error[E0063]: missing field 'stop_wakeup' in initializer of 'CursorActor'.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 228-232

Comment:
**Missing `stop_wakeup` field — Linux compile error**

The `CursorActor` struct now has four fields (`stop`, `stop_wakeup`, `thread`, `rx`), but this Wayland early-return initializer only sets `stop`, `thread`, and `rx` — it omits `stop_wakeup: None`. Rust requires all fields to be listed in a struct literal, so the Linux build will fail with `error[E0063]: missing field 'stop_wakeup' in initializer of 'CursorActor'`.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai please rereview

@richiemcilroy richiemcilroy merged commit db4057a into main Jun 10, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant