diff --git a/.github/workflows/lockdown_release.yml b/.github/workflows/lockdown_release.yml new file mode 100644 index 00000000..2c2e13f6 --- /dev/null +++ b/.github/workflows/lockdown_release.yml @@ -0,0 +1,247 @@ +name: Lockdown Systems Release + +on: + push: + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + +jobs: + build_linux_x64: + name: Build Linux x64 + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libpulse-dev cmake build-essential + + - name: Install Rust + run: | + curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - uses: actions/setup-node@v4 + with: + node-version-file: "src/node/.nvmrc" + + - name: Fetch WebRTC artifact + run: ./bin/fetch-artifact --platform linux-x64 --release + + - name: Build RingRTC + run: ./bin/build-desktop --ringrtc-only --release + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-linux-x64 + path: src/node/build/ + retention-days: 1 + + build_linux_arm64: + name: Build Linux ARM64 + runs-on: ubuntu-22.04-arm64-4-cores + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libpulse-dev cmake build-essential + + - uses: actions/setup-node@v4 + with: + node-version-file: "src/node/.nvmrc" + + - name: Fetch WebRTC artifact + run: ./bin/fetch-artifact --platform linux-arm64 --release + + - name: Build RingRTC + run: TARGET_ARCH=arm64 ./bin/build-desktop --ringrtc-only --release + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-linux-arm64 + path: src/node/build/ + retention-days: 1 + + build_macos: + name: Build macOS (x64 + ARM64) + runs-on: macos-14 + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: brew install protobuf cmake + + - uses: actions/setup-node@v4 + with: + node-version-file: "src/node/.nvmrc" + + # Build x64 + - name: Fetch WebRTC artifact (x64) + run: ./bin/fetch-artifact --platform mac-x64 --release + + - name: Build RingRTC (x64) + run: TARGET_ARCH=x64 ./bin/build-desktop --ringrtc-only --release + + # Build ARM64 + - name: Fetch WebRTC artifact (ARM64) + run: ./bin/fetch-artifact --platform mac-arm64 --release -o out-arm + + - name: Build RingRTC (ARM64) + run: OUTPUT_DIR=out-arm TARGET_ARCH=arm64 ./bin/build-desktop --ringrtc-only --release + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-macos + path: src/node/build/ + retention-days: 1 + + build_windows: + name: Build Windows (x64 + ARM64) + runs-on: windows-2022 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup target add aarch64-pc-windows-msvc + + - name: Install protoc + run: choco install protoc + shell: cmd + + - uses: actions/setup-node@v4 + with: + node-version-file: "src/node/.nvmrc" + + # Build x64 + - name: Fetch WebRTC artifact (x64) + run: sh ./bin/fetch-artifact --platform windows-x64 --release + + - name: Build RingRTC (x64) + run: sh ./bin/build-desktop --ringrtc-only --release + + # Build ARM64 + - name: Fetch WebRTC artifact (ARM64) + run: sh ./bin/fetch-artifact --platform windows-arm64 --release -o out-arm + + - name: Build RingRTC (ARM64) + run: | + echo "TARGET_ARCH=arm64" >> $env:GITHUB_ENV + echo "OUTPUT_DIR=out-arm" >> $env:GITHUB_ENV + + - name: Build RingRTC (ARM64) - continued + run: sh ./bin/build-desktop --ringrtc-only --release + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-windows + path: src/node/build/ + retention-days: 1 + + publish: + name: Publish to npm + needs: [build_linux_x64, build_linux_arm64, build_macos, build_windows] + runs-on: ubuntu-latest + + permissions: + contents: write # Needed for creating releases + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: "src/node/.nvmrc" + registry-url: "https://registry.npmjs.org/" + + # Download all build artifacts + - name: Download Linux x64 build + uses: actions/download-artifact@v4 + with: + name: build-linux-x64 + path: src/node/build/ + + - name: Download Linux ARM64 build + uses: actions/download-artifact@v4 + with: + name: build-linux-arm64 + path: src/node/build/ + + - name: Download macOS build + uses: actions/download-artifact@v4 + with: + name: build-macos + path: src/node/build/ + + - name: Download Windows build + uses: actions/download-artifact@v4 + with: + name: build-windows + path: src/node/build/ + + - name: List build directory + run: ls -la src/node/build/ + + # Determine version from package.json + - name: Determine version + id: version + run: | + VERSION=$(jq -r .version src/node/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Create tarball for GitHub Releases + - name: Create prebuild archive + run: tar czf "ringrtc-desktop-build-v${{ steps.version.outputs.version }}.tar.gz" build + working-directory: src/node + + - name: Calculate checksum + id: checksum + run: | + CHECKSUM=$(sha256sum "ringrtc-desktop-build-v${{ steps.version.outputs.version }}.tar.gz" | cut -d ' ' -f 1) + echo "sha256=$CHECKSUM" >> $GITHUB_OUTPUT + echo "Checksum: $CHECKSUM" + working-directory: src/node + + # Upload to GitHub Releases + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: src/node/ringrtc-desktop-build-v${{ steps.version.outputs.version }}.tar.gz + generate_release_notes: true + + # Update package.json with checksum + - name: Update prebuild checksum + run: | + sed -i 's/"prebuildChecksum": ""/"prebuildChecksum": "${{ steps.checksum.outputs.sha256 }}"/' package.json + working-directory: src/node + + # Install and build + - name: Install dependencies + run: npm ci + working-directory: src/node + + - name: Build TypeScript + run: npm run build + working-directory: src/node + + # Publish to npm + - name: Publish to npm + run: npm publish --access public + working-directory: src/node + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/node/package-lock.json b/src/node/package-lock.json index 0fb1524e..5a6233a3 100644 --- a/src/node/package-lock.json +++ b/src/node/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@signalapp/ringrtc", - "version": "2.63.0", + "name": "@lockdown-systems/ringrtc", + "version": "2.63.0-audiosink.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@signalapp/ringrtc", - "version": "2.63.0", + "name": "@lockdown-systems/ringrtc", + "version": "2.63.0-audiosink.1", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/src/node/package.json b/src/node/package.json index c66abd0c..9c69bbcd 100644 --- a/src/node/package.json +++ b/src/node/package.json @@ -1,9 +1,9 @@ { - "name": "@signalapp/ringrtc", - "version": "2.63.0", + "name": "@lockdown-systems/ringrtc", + "version": "2.63.0-audiosink.1", "repository": { "type": "git", - "url": "https://github.com/signalapp/ringrtc.git", + "url": "https://github.com/lockdown-systems/ringrtc.git", "directory": "src/node" }, "description": "Signal Messenger voice and video calling library.", @@ -32,7 +32,7 @@ "prepublishOnly": "node scripts/prepublish.js" }, "config": { - "prebuildUrl": "https://build-artifacts.signal.org/libraries/ringrtc-desktop-build-v${npm_package_version}.tar.gz", + "prebuildUrl": "https://github.com/lockdown-systems/ringrtc/releases/download/v${npm_package_version}/ringrtc-desktop-build-v${npm_package_version}.tar.gz", "prebuildChecksum": "" }, "author": "", diff --git a/src/node/ringrtc/Service.ts b/src/node/ringrtc/Service.ts index 52c2615e..d2a3b8a5 100644 --- a/src/node/ringrtc/Service.ts +++ b/src/node/ringrtc/Service.ts @@ -116,6 +116,10 @@ class NativeCallManager { (NativeCallManager.prototype as any).sendVideoFrame = Native.cm_sendVideoFrame; (NativeCallManager.prototype as any).receiveVideoFrame = Native.cm_receiveVideoFrame; +(NativeCallManager.prototype as any).setAudioCaptureEnabled = + Native.cm_setAudioCaptureEnabled; +(NativeCallManager.prototype as any).receiveAudioSamples = + Native.cm_receiveAudioSamples; (NativeCallManager.prototype as any).receiveGroupCallVideoFrame = Native.cm_receiveGroupCallVideoFrame; (NativeCallManager.prototype as any).createGroupCallClient = @@ -2290,6 +2294,20 @@ export class Call { return this._callManager.receiveVideoFrame(buffer, maxWidth, maxHeight); } + // Enable or disable audio capture from the call. + // When enabled, call audio will be buffered and can be retrieved via receiveAudioSamples. + setAudioCaptureEnabled(enabled: boolean): void { + this._callManager.setAudioCaptureEnabled(enabled); + } + + // Receive audio samples from the call. + // Returns { samplesWritten: number, sampleRate: number } or undefined if no samples available. + receiveAudioSamples( + buffer: Int16Array + ): { samplesWritten: number; sampleRate: number } | undefined { + return this._callManager.receiveAudioSamples(buffer); + } + updateDataMode(dataMode: DataMode): void { sillyDeadlockProtection(() => { try { @@ -2567,6 +2585,20 @@ export class GroupCall { this._observer.onLocalDeviceStateChanged(this); } + // Enable or disable audio capture from the call. + // When enabled, call audio will be buffered and can be retrieved via receiveAudioSamples. + setAudioCaptureEnabled(enabled: boolean): void { + this._callManager.setAudioCaptureEnabled(enabled); + } + + // Receive audio samples from the call. + // Returns { samplesWritten: number, sampleRate: number } or undefined if no samples available. + receiveAudioSamples( + buffer: Int16Array + ): { samplesWritten: number; sampleRate: number } | undefined { + return this._callManager.receiveAudioSamples(buffer); + } + // Called by UI setOutgoingAudioMutedRemotely(source: number): void { this._localDeviceState.audioMuted = true; @@ -2999,6 +3031,14 @@ export interface CallManager { maxWidth: number, maxHeight: number ): [number, number] | undefined; + // Enable or disable audio capture from the call. + // When enabled, call audio will be buffered and can be retrieved via receiveAudioSamples. + setAudioCaptureEnabled(enabled: boolean): void; + // Receive audio samples from the call. + // Returns { samplesWritten: number, sampleRate: number } or undefined if no samples available. + receiveAudioSamples( + buffer: Int16Array + ): { samplesWritten: number; sampleRate: number } | undefined; receivedOffer( remoteUserId: UserId, remoteDeviceId: DeviceId, diff --git a/src/rust/src/electron.rs b/src/rust/src/electron.rs index 26a4afa8..6040dfaf 100644 --- a/src/rust/src/electron.rs +++ b/src/rust/src/electron.rs @@ -417,6 +417,10 @@ pub struct CallEndpoint { outgoing_video_track: VideoTrack, // Boxed so we can pass it as a Box incoming_video_sink: Box, + // Audio sink for capturing playback audio (Observer Vault) + incoming_audio_sink: Box, + // Whether audio capture is enabled + audio_capture_enabled: bool, peer_connection_factory: PeerConnectionFactory, @@ -509,6 +513,7 @@ impl CallEndpoint { peer_connection_factory.create_outgoing_video_track(&outgoing_video_source)?; outgoing_video_track.set_enabled(false); let incoming_video_sink = Box::::default(); + let incoming_audio_sink = Box::::default(); // After initializing logs, log the backend in use. let backend = peer_connection_factory.audio_backend(); @@ -540,6 +545,8 @@ impl CallEndpoint { outgoing_video_source, outgoing_video_track, incoming_video_sink, + incoming_audio_sink, + audio_capture_enabled: false, peer_connection_factory, js_object, most_recent_overlarge_frame_dimensions: (0, 0), @@ -578,6 +585,54 @@ impl LastFramesVideoSink { } } +/// Audio buffer that stores samples for JavaScript to poll. +/// Samples are appended by the audio device module and popped by JavaScript. +#[derive(Clone, Default, Debug)] +struct LastAudioFramesSink { + /// Circular buffer of audio samples (48kHz, mono, i16) + samples: Arc>>, + /// The sample rate of the audio + sample_rate: Arc>, +} + +impl crate::webrtc::media::AudioSink for LastAudioFramesSink { + fn on_audio_samples(&self, samples: &[i16], sample_rate: u32) { + let mut buffer = self.samples.lock().unwrap(); + // Limit buffer size to ~1 second of audio to prevent unbounded growth + const MAX_SAMPLES: usize = 48000; + if buffer.len() + samples.len() > MAX_SAMPLES { + // Drop oldest samples to make room + let to_remove = buffer.len() + samples.len() - MAX_SAMPLES; + let drain_count = to_remove.min(buffer.len()); + buffer.drain(0..drain_count); + } + buffer.extend_from_slice(samples); + + let mut rate = self.sample_rate.lock().unwrap(); + *rate = sample_rate; + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +impl LastAudioFramesSink { + /// Pop up to `max_samples` audio samples from the buffer. + fn pop(&self, max_samples: usize) -> (Vec, u32) { + let mut buffer = self.samples.lock().unwrap(); + let sample_rate = *self.sample_rate.lock().unwrap(); + + let count = max_samples.min(buffer.len()); + let samples: Vec = buffer.drain(0..count).collect(); + (samples, sample_rate) + } + + fn clear(&self) { + self.samples.lock().unwrap().clear(); + } +} + fn js_num_to_u64(num: f64) -> u64 { // Convert safely from signed. num as i32 as u32 as u64 @@ -1464,6 +1519,72 @@ fn receiveVideoFrame(mut cx: FunctionContext) -> JsResult { receive_video_frame(&mut cx, rgba_buffer, 0, max_width, max_height) } +/// Enable or disable audio capture from the call. +/// When enabled, call audio will be buffered and can be retrieved via receiveAudioSamples. +/// This allows capturing call audio directly without system audio loopback. +#[allow(non_snake_case)] +fn setAudioCaptureEnabled(mut cx: FunctionContext) -> JsResult { + let enabled = cx.argument::(0)?.value(&mut cx); + + with_call_endpoint(&mut cx, |endpoint| { + if enabled && !endpoint.audio_capture_enabled { + // Enable audio capture - set the audio sink on the audio device module + // Clone the inner sink and box it + let sink: Box = + Box::new((*endpoint.incoming_audio_sink).clone()); + endpoint.peer_connection_factory.set_audio_sink(Some(sink))?; + endpoint.audio_capture_enabled = true; + info!("Audio capture enabled"); + } else if !enabled && endpoint.audio_capture_enabled { + // Disable audio capture + endpoint.peer_connection_factory.set_audio_sink(None)?; + endpoint.incoming_audio_sink.clear(); + endpoint.audio_capture_enabled = false; + info!("Audio capture disabled"); + } + Ok(()) + }) + .or_else(|err: anyhow::Error| cx.throw_error(format!("{}", err)))?; + + Ok(cx.undefined()) +} + +/// Receive audio samples from the call. +/// Takes a pre-allocated Int16Array buffer and fills it with samples. +/// Returns an object with { samplesWritten: number, sampleRate: number } or undefined if no samples available. +#[allow(non_snake_case)] +fn receiveAudioSamples(mut cx: FunctionContext) -> JsResult { + let mut buffer = cx.argument::>(0)?; + let max_samples = buffer.len(&mut cx); + + let result: anyhow::Result<(Vec, u32)> = with_call_endpoint(&mut cx, |endpoint| { + let (samples, sample_rate) = endpoint.incoming_audio_sink.pop(max_samples); + Ok((samples, sample_rate)) + }); + + match result { + Ok((samples, sample_rate)) => { + if samples.is_empty() { + Ok(cx.undefined().upcast()) + } else { + // Copy samples to the provided buffer + let slice = buffer.as_mut_slice(&mut cx); + let count = samples.len().min(slice.len()); + slice[..count].copy_from_slice(&samples[..count]); + + let js_samples_written = cx.number(count as f64); + let js_sample_rate = cx.number(sample_rate); + + let result = cx.empty_object(); + result.set(&mut cx, "samplesWritten", js_samples_written)?; + result.set(&mut cx, "sampleRate", js_sample_rate)?; + Ok(result.upcast()) + } + } + Err(err) => cx.throw_error(format!("{}", err)), + } +} + // Group Calls #[allow(non_snake_case)] @@ -3278,6 +3399,8 @@ fn register(mut cx: ModuleContext) -> NeonResult<()> { )?; cx.export_function("cm_sendVideoFrame", sendVideoFrame)?; cx.export_function("cm_receiveVideoFrame", receiveVideoFrame)?; + cx.export_function("cm_setAudioCaptureEnabled", setAudioCaptureEnabled)?; + cx.export_function("cm_receiveAudioSamples", receiveAudioSamples)?; cx.export_function("cm_receiveGroupCallVideoFrame", receiveGroupCallVideoFrame)?; cx.export_function("cm_createGroupCallClient", createGroupCallClient)?; cx.export_function("cm_createCallLinkCallClient", createCallLinkCallClient)?; diff --git a/src/rust/src/webrtc/audio_device_module.rs b/src/rust/src/webrtc/audio_device_module.rs index eccfa472..e5fa17cb 100644 --- a/src/rust/src/webrtc/audio_device_module.rs +++ b/src/rust/src/webrtc/audio_device_module.rs @@ -97,6 +97,7 @@ enum Event { PlayoutDelay, Terminate, RegisterAudioObserver(Box), + SetAudioSink(Option>), } #[derive(Debug, Clone)] @@ -126,6 +127,8 @@ struct Worker { audio_transport: Arc>, audio_device_observer: Option>, send_to_webrtc: Arc, + // Optional audio sink for capturing playback audio (Observer Vault) + audio_sink: Option>>>>, } impl Worker { @@ -280,6 +283,8 @@ impl Worker { .take(); let mut builder = cubeb::StreamBuilder::::new(); let transport = Arc::clone(&self.audio_transport); + // Clone the audio sink Arc for use in the closure + let audio_sink = self.audio_sink.clone(); let min_latency = self.ctx.min_latency(¶ms).unwrap_or_else(|e| { warn!( "Could not get min latency for playout; using default: {:?}", @@ -334,6 +339,16 @@ impl Worker { error!("need_more_play_data returned too much data"); return -1; } + + // Send audio to the audio sink if one is registered (Observer Vault) + if let Some(ref sink_arc) = audio_sink { + if let Ok(sink_guard) = sink_arc.lock() { + if let Some(ref sink) = *sink_guard { + sink.on_audio_samples(&play_data.data, SAMPLE_FREQUENCY); + } + } + } + // Put data into the right format and add it to the output // array for cubeb to play. // If there's more data than was requested, add it to the @@ -638,6 +653,14 @@ impl Worker { self.audio_device_observer = Some(audio_device_observer); continue; } + Event::SetAudioSink(sink) => { + if let Some(ref sink_arc) = self.audio_sink { + if let Ok(mut guard) = sink_arc.lock() { + *guard = sink; + } + } + continue; + } } { warn!("{:?} failed: {:?}", received, e); } @@ -816,6 +839,7 @@ impl Worker { })), audio_device_observer: None, send_to_webrtc: Arc::new(AtomicBool::new(true)), + audio_sink: Some(Arc::new(Mutex::new(None))), }; if let Err(e) = worker.register_device_collection_changed(DeviceType::INPUT) { error!("Failed to register input device callback: {}", e); @@ -1677,6 +1701,19 @@ impl AudioDeviceModule { } Ok(()) } + + /// Set an audio sink to receive playback audio samples. + /// This allows capturing call audio directly from WebRTC without system permissions. + /// Pass None to remove an existing sink. + pub fn set_audio_sink( + &mut self, + sink: Option>, + ) -> anyhow::Result<()> { + if let Err(e) = self.mpsc_sender.send(Event::SetAudioSink(sink)) { + bail!("Failed to send SetAudioSink request: {}", e); + } + Ok(()) + } } #[cfg(test)] diff --git a/src/rust/src/webrtc/media.rs b/src/rust/src/webrtc/media.rs index d15e9d64..05fa4649 100644 --- a/src/rust/src/webrtc/media.rs +++ b/src/rust/src/webrtc/media.rs @@ -333,6 +333,22 @@ impl Clone for Box { } } +/// Trait for receiving audio samples from the audio device module. +/// This allows capturing the audio that would be played to the speaker. +pub trait AudioSink: Sync + Send + std::fmt::Debug { + /// Called with audio samples that are about to be played. + /// samples: interleaved PCM samples (mono i16) + /// sample_rate: samples per second (typically 48000) + fn on_audio_samples(&self, samples: &[i16], sample_rate: u32); + fn box_clone(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.box_clone() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "call_sim", derive(clap::ValueEnum))] #[repr(i32)] diff --git a/src/rust/src/webrtc/peer_connection_factory.rs b/src/rust/src/webrtc/peer_connection_factory.rs index a66b367a..7b416a5a 100644 --- a/src/rust/src/webrtc/peer_connection_factory.rs +++ b/src/rust/src/webrtc/peer_connection_factory.rs @@ -752,4 +752,20 @@ impl PeerConnectionFactory { }; Ok(()) } + + /// Set an audio sink to receive playback audio samples. + /// This allows capturing call audio directly from WebRTC without system permissions. + /// Pass None to remove an existing sink. + #[cfg(all(not(feature = "sim"), feature = "native"))] + pub fn set_audio_sink( + &mut self, + sink: Option>, + ) -> Result<()> { + self.adm + .as_ref() + .and_then(|adm| adm.lock().ok()) + .map_or(Err(anyhow!("couldn't access ADM")), |mut adm| { + adm.set_audio_sink(sink) + }) + } }