Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actions/install-desktop-deps/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ runs:
libpipewire-0.3-dev \
libspa-0.2-dev \
libasound2-dev \
libasound2-plugins \
pulseaudio-utils \
libdbus-1-dev \
libudev-dev \
libx11-dev \
Expand All @@ -49,4 +51,8 @@ runs:
libva-dev \
xvfb \
xauth \
rpm \
cpio \
libarchive-tools \
zstd \
patchelf
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ jobs:
shell: bash
run: node scripts/setup.js

- name: Test desktop installation and storage helpers
shell: bash
run: cargo test --locked -p cap-utils -p cap-cli-install --lib

- name: Build desktop binaries
shell: bash
run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }}
Expand Down Expand Up @@ -270,10 +274,14 @@ jobs:
shell: bash
run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }}

- name: Test Linux recording and encoder regressions
- name: Test Linux desktop regressions
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
node --test scripts/linux-bundle-config.test.mjs scripts/finalize-linux-appimage.test.mjs
pnpm --dir apps/desktop exec vitest run scripts/prepare.test.js
cargo test --locked -p cap-utils -p cap-cli-install --lib
cargo test --locked -p cap --bin cap record::tests
cargo test --locked -p cap-recording --lib
cargo test --locked -p cap-enc-ffmpeg
env:
Expand Down
104 changes: 94 additions & 10 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ jobs:
console.error("package.version line not found in " + path);
process.exit(1);
}
toml = toml.replace(re, `version = "${version}"`);
toml = toml.replace(re, "version = \"" + version + "\"");
fs.writeFileSync(path, toml);
console.log("Stamped " + path + " with " + version);
'
Expand Down Expand Up @@ -340,7 +340,7 @@ jobs:

- name: Build app
working-directory: apps/desktop
run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 ' || '' }}pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--bundles deb --verbose' || '' }}
run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 node ../../scripts/build-linux-packages.mjs' || 'pnpm build:tauri --target' }} ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--verbose' || '' }}
env:
# https://github.com/tauri-apps/tauri-action/issues/740
CI: false
Expand Down Expand Up @@ -426,12 +426,6 @@ jobs:
fi
echo "Frontend build output OK ($FE, $(wc -c < "$FE") bytes)"

UNSUPPORTED_ARTIFACT="$(find target/${{ matrix.settings.target }}/release/bundle -type f \( -name '*.AppImage' -o -name '*.rpm' \) -print -quit)"
if [[ -n "$UNSUPPORTED_ARTIFACT" ]]; then
echo "::error::Unexpected Linux non-deb artifact produced: $UNSUPPORTED_ARTIFACT"
exit 1
fi

DEB="$(find target/${{ matrix.settings.target }}/release/bundle/deb -name '*.deb' | head -n1)"
if [[ -z "$DEB" ]]; then echo "::error::No .deb produced"; exit 1; fi
if [[ ! -s "$DEB.sig" ]]; then echo "::error::No .deb updater signature produced next to $DEB"; exit 1; fi
Expand All @@ -450,7 +444,7 @@ jobs:
done

GPUI_RPATH="$(objdump -p "$WORK/usr/bin/cap-gpui" | awk '/RPATH|RUNPATH/{print $2}')"
if [[ "$GPUI_RPATH" != *'$ORIGIN/../lib/cap'* ]]; then
if [[ "$GPUI_RPATH" != *"\$ORIGIN/../lib/cap"* ]]; then
echo "::error::The bundled GPUI executable cannot resolve shared libraries from /usr/lib/cap"
exit 1
fi
Expand All @@ -464,7 +458,6 @@ jobs:
fi

# (2) every FFmpeg soname the binary NEEDs is bundled
BIN="$WORK/usr/bin/Cap"
NEEDED="$(for executable in "${EXECUTABLES[@]}"; do objdump -p "$executable"; done | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' | sort -u || true)"
echo "Binary NEEDs FFmpeg sonames:"; echo "${NEEDED:-(none)}"
echo "Bundled FFmpeg libs:"; ls -1 "$WORK/usr/lib/cap" 2>/dev/null || true
Expand Down Expand Up @@ -519,6 +512,73 @@ jobs:
fi
echo "Runtime dependency declarations OK"

- name: Verify Linux RPM and AppImage contents
if: ${{ runner.os == 'Linux' }}
shell: bash
env:
CAP_RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
BUNDLE="${{ github.workspace }}/target/${{ matrix.settings.target }}/release/bundle"
RPM="$BUNDLE/rpm/Cap-$CAP_RELEASE_VERSION-1.x86_64.rpm"
APPIMAGE="$BUNDLE/appimage/Cap_${CAP_RELEASE_VERSION}_amd64.AppImage"
test -s "$APPIMAGE.sig"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir "$WORK/rpm" "$WORK/appimage"
(cd "$WORK/appimage" && "$APPIMAGE" --appimage-extract > /dev/null)
APPROOT="$WORK/appimage/squashfs-root"
ROOTS=("$APPROOT")
if [[ "$CAP_RELEASE_VERSION" != *-* ]]; then
test -s "$RPM"
test "$(rpm -qp --queryformat '%{VERSION}-%{RELEASE}' "$RPM")" = "$CAP_RELEASE_VERSION-1"
rpm -Kv "$RPM"
bsdtar -xf "$RPM" -C "$WORK/rpm"
test "$(cat "$WORK/rpm/usr/lib/cap/package-format")" = rpm
ROOTS+=("$WORK/rpm")
fi
for ROOT in "${ROOTS[@]}"; do
for binary in Cap cap-muxer cap-exporter cap-cli cap-gpui; do
test -x "$ROOT/usr/bin/$binary"
while read -r soname; do
test -f "$ROOT/usr/lib/cap/$soname"
done < <(objdump -p "$ROOT/usr/bin/$binary" | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' || true)
done
done
test "$(cat "$APPROOT/usr/lib/cap/package-format")" = appimage
test -x "$APPROOT/usr/bin/pactl"
test -s "$APPROOT/usr/lib/alsa-lib/libasound_module_pcm_pulse.so"
test -s "$APPROOT/usr/lib/cap/alsa-pulse.conf"
if [[ -n "$(find "$APPROOT" -name 'libwayland-client.so*' -print -quit)" ]]; then
echo "::error::AppImage bundles a Wayland client that can conflict with host Mesa drivers"
exit 1
fi
if [[ -n "$(find "$APPROOT/usr/lib" -maxdepth 1 -name 'libpipewire-0.3.so*' -print -quit)" ]]; then
echo "::error::AppImage bundles PipeWire that can conflict with host ALSA plugins"
exit 1
fi

- name: Build Arch Linux package
id: build_arch
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
BUNDLE="target/${{ matrix.settings.target }}/release/bundle"
mkdir -p "$BUNDLE/arch"
docker run --rm --network none \
--env CAP_PACKAGE_UID="$(id -u)" \
--volume "${{ github.workspace }}:/source:ro" \
--volume "${{ github.workspace }}/$BUNDLE/arch:/output" \
archlinux:base-devel@sha256:68bfc3b0d277b08a99101dc9b94aaa03e5ae70cf1b4fb965c03b2b87b915760d \
bash -c 'useradd --uid "$CAP_PACKAGE_UID" --create-home cap-package && runuser -u cap-package -- bash /source/scripts/build-linux-arch-package.sh "$1" /output' \
bash "/source/$BUNDLE/deb/Cap_${{ inputs.version }}_amd64.deb"
PACKAGES=("$BUNDLE/arch/"*.pkg.tar.zst)
test "${#PACKAGES[@]}" -eq 1
test -s "${PACKAGES[0]}"
test "$(bsdtar -xOf "${PACKAGES[0]}" usr/lib/cap/package-format)" = arch
echo "package=../../${PACKAGES[0]}" >> "$GITHUB_OUTPUT"

- name: Verify Windows installer contents
if: ${{ runner.os == 'Windows' }}
shell: pwsh
Expand Down Expand Up @@ -659,6 +719,30 @@ jobs:
command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb" --signature "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb.sig" --public-platform deb-x86_64 --update-platform linux-x86_64-deb ${{ env.CN_CHANNEL_FLAG }}
api-key: ${{ secrets.CN_API_KEY }}

- name: Upload Linux AppImage asset
if: ${{ runner.os == 'Linux' }}
uses: crabnebula-dev/cloud-release@v0
with:
working-directory: apps/desktop
command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/appimage/Cap_${{ inputs.version }}_amd64.AppImage" --signature "../../target/${{ matrix.settings.target }}/release/bundle/appimage/Cap_${{ inputs.version }}_amd64.AppImage.sig" --public-platform appimage-x86_64 --update-platform linux-x86_64-appimage ${{ env.CN_CHANNEL_FLAG }}
api-key: ${{ secrets.CN_API_KEY }}

- name: Upload Linux RPM asset
if: ${{ runner.os == 'Linux' && !contains(inputs.version, '-') }}
uses: crabnebula-dev/cloud-release@v0
with:
working-directory: apps/desktop
command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/rpm/Cap-${{ inputs.version }}-1.x86_64.rpm" --public-platform rpm-x86_64 ${{ env.CN_CHANNEL_FLAG }}
api-key: ${{ secrets.CN_API_KEY }}

- name: Upload Arch Linux asset
if: ${{ runner.os == 'Linux' }}
uses: crabnebula-dev/cloud-release@v0
with:
working-directory: apps/desktop
command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "${{ steps.build_arch.outputs.package }}" --public-platform pacman-x86_64 ${{ env.CN_CHANNEL_FLAG }}
api-key: ${{ secrets.CN_API_KEY }}

- uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e

- name: Upload debug symbols to Sentry
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/sync-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ jobs:
shell: bash
run: |
cargo test --locked -p cap-editor --lib audio::tests::
cargo test --locked -p cap-editor --lib audio_output::tests::
cargo test --locked -p cap-editor --lib playback::tests::

# Real encoders + DASH muxer + remux/validation over full instant-mode
# scenarios: pause/resume excision, stall-recovery bursts with
Expand Down
114 changes: 100 additions & 14 deletions apps/cli/src/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use cap_project::{
InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner,
};
use cap_recording::{
CameraFeed, MicrophoneFeed,
CameraFeed, DoneFut, MicrophoneFeed, PipelineStoppedByUser,
feeds::{camera, microphone},
instant_recording,
screen_capture::ScreenCaptureTarget,
Expand Down Expand Up @@ -697,19 +697,26 @@ enum ActorHandle {
}

impl ActorHandle {
fn done_fut(&self) -> DoneFut {
match self {
Self::Studio(actor) => actor.done_fut(),
Self::Instant(actor) => actor.done_fut(),
}
}

async fn stop(&self) -> Result<CompletedRecording, String> {
match self {
Self::Studio(actor) => actor
.stop()
.await
.map(Box::new)
.map(CompletedRecording::Studio)
.map_err(|e| e.to_string()),
.map_err(|e| format!("{e:#}")),
Self::Instant(actor) => actor
.stop()
.await
.map(CompletedRecording::Instant)
.map_err(|e| e.to_string()),
.map_err(|e| format!("{e:#}")),
}
}
}
Expand Down Expand Up @@ -826,6 +833,10 @@ async fn start_recording(
}
RecordMode::Instant => {
let mut builder = instant_builder;
#[cfg(target_os = "linux")]
if camera_active {
builder = builder.with_linux_camera_composition();
}
builder = builder.with_max_output_size(
cap_recording::RecordingDefaults::default().instant_mode_max_resolution,
);
Expand Down Expand Up @@ -935,22 +946,44 @@ async fn finalize(
stop_file: Option<&Path>,
) -> Result<CompletedRecording, String> {
let outcome = std::panic::AssertUnwindSafe(async {
wait_for_stop(duration, interactive, stop_file).await;
actor.stop().await.map_err(|e| e.to_string())
finalize_after_stop_trigger(
actor.done_fut().map(|result| match result {
Err(error) if error.is_caused_by::<PipelineStoppedByUser>() => Ok(()),
result => result.map_err(|error| error.to_string()),
}),
wait_for_stop(duration, interactive, stop_file),
async { finalize_completed(actor.stop().await?).await },
)
.await
})
.catch_unwind()
.await;

let completed = match outcome {
Ok(Ok(completed)) => completed,
Ok(Err(error)) => return Err(error),
Err(_) => actor
.stop()
.await
.map_err(|e| format!("recording panicked; finalize failed: {e}"))?,
};
match outcome {
Ok(result) => result,
Err(_) => {
let completed = actor
.stop()
.await
.map_err(|e| format!("recording panicked; finalize failed: {e}"))?;
finalize_completed(completed).await
}
}
}

finalize_completed(completed).await
async fn finalize_after_stop_trigger<T>(
capture_done: impl Future<Output = Result<(), String>>,
stop_requested: impl Future<Output = ()>,
finalize: impl Future<Output = Result<T, String>>,
) -> Result<T, String> {
let capture_result = tokio::select! {
biased;
result = capture_done => result,
_ = stop_requested => Ok(()),
};
let finalized = finalize.await;
capture_result?;
finalized
}

async fn finalize_completed(completed: CompletedRecording) -> Result<CompletedRecording, String> {
Expand Down Expand Up @@ -1202,6 +1235,59 @@ fn emit_record_event(format: OutputFormat, event: &RecordEvent<'_>) -> Result<()
mod tests {
use super::*;

#[tokio::test]
async fn capture_failure_finalizes_without_waiting_for_stop_request() {
let finalized = std::cell::Cell::new(false);
let result = finalize_after_stop_trigger(
std::future::ready(Err("capture window disappeared".to_string())),
std::future::pending(),
async {
finalized.set(true);
Err::<(), _>("display".to_string())
},
)
.await;

assert!(finalized.get());
assert_eq!(result.unwrap_err(), "capture window disappeared");
}

#[tokio::test]
async fn stop_request_finalizes_while_capture_is_running() {
let result = finalize_after_stop_trigger(
std::future::pending(),
std::future::ready(()),
std::future::ready(Ok(42)),
)
.await;

assert_eq!(result.unwrap(), 42);
}

#[tokio::test]
async fn completed_capture_finalizes_without_stop_request() {
let result = finalize_after_stop_trigger(
std::future::ready(Ok(())),
std::future::pending(),
std::future::ready(Ok(42)),
)
.await;

assert_eq!(result.unwrap(), 42);
}

#[tokio::test]
async fn finalization_error_is_returned_after_stop_request() {
let result = finalize_after_stop_trigger(
std::future::pending(),
std::future::ready(()),
std::future::ready(Err::<(), _>("failed to finalize recording".to_string())),
)
.await;

assert_eq!(result.unwrap_err(), "failed to finalize recording");
}

#[test]
fn record_event_fields_are_camel_case() {
let started = serde_json::to_value(RecordEvent::Started {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-gpui/assets/icons/caption-close-windows.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop-gpui/assets/icons/caption-maximize-windows.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop-gpui/assets/icons/caption-minimize-windows.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading