Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e38c29794 | ||
|
|
2e9716d85d | ||
|
|
ee7ecd34bd | ||
|
|
6fcdf357f7 | ||
|
|
5988785d57 | ||
|
|
e02bfefd72 | ||
|
|
6c9b8a8d82 | ||
|
|
d01cdcb449 | ||
|
|
16edf3c693 | ||
| db7cbf93bb | |||
| e7a83ba472 | |||
| c3459bee4d | |||
| 3cc718fa69 | |||
| 425a9b1003 |
21 changed files with 1473 additions and 702 deletions
113
.github/workflows/ci.yml
vendored
Normal file
113
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: "1"
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Linux (check, test, build)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config clang libva-dev \
|
||||
libavcodec-dev libavformat-dev libavutil-dev \
|
||||
libswresample-dev libswscale-dev libavfilter-dev libavdevice-dev \
|
||||
libpipewire-0.3-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev \
|
||||
libasound2-dev
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo registry and target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: linux-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: linux-
|
||||
|
||||
- name: Format check (advisory)
|
||||
continue-on-error: true
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Type check
|
||||
run: cargo check --locked --all-targets
|
||||
|
||||
- name: Unit tests
|
||||
run: cargo test --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --locked --all-targets -- -D warnings
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --locked --release
|
||||
|
||||
- name: Verify binary
|
||||
run: |
|
||||
./target/release/scythe --help | head -3
|
||||
test -x target/release/scythe
|
||||
ls -la target/release/scythe
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scythe-linux-x86_64
|
||||
path: target/release/scythe
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Windows (cross build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
update: true
|
||||
path-type: minimal
|
||||
install: >-
|
||||
mingw-w64-x86_64-toolchain
|
||||
mingw-w64-x86_64-rust
|
||||
mingw-w64-x86_64-ffmpeg
|
||||
mingw-w64-x86_64-pkg-config
|
||||
mingw-w64-x86_64-clang
|
||||
|
||||
- name: Build release
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
LIBCLANG_PATH: /mingw64/bin
|
||||
BINDGEN_EXTRA_CLANG_ARGS: "--target=x86_64-w64-windows-gnu"
|
||||
run: |
|
||||
cargo build --release
|
||||
ls -la target/release/scythe.exe
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scythe-windows-x86_64
|
||||
path: target/release/scythe.exe
|
||||
if-no-files-found: error
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
|
|
@ -33,14 +33,15 @@ jobs:
|
|||
- name: Build Release Binaries
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package Linux Tarball (Generic Binaries)
|
||||
- name: Package Linux Tarball (Unified Binary)
|
||||
run: |
|
||||
mkdir -p dist/scythe-linux-x86_64
|
||||
cp target/release/scythe-daemon target/release/scythe-ui dist/scythe-linux-x86_64/
|
||||
ln -sf scythe-daemon dist/scythe-linux-x86_64/vrec-daemon
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/vrec-ui
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/scythe
|
||||
ln -sf scythe-ui dist/scythe-linux-x86_64/vrec
|
||||
cp target/release/scythe dist/scythe-linux-x86_64/
|
||||
ln -sf scythe dist/scythe-linux-x86_64/scythe-daemon
|
||||
ln -sf scythe dist/scythe-linux-x86_64/scythe-ui
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec-daemon
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec-ui
|
||||
ln -sf scythe dist/scythe-linux-x86_64/vrec
|
||||
cp packaging/scythe.desktop dist/scythe-linux-x86_64/
|
||||
tar -czvf dist/scythe-linux-x86_64.tar.gz -C dist scythe-linux-x86_64
|
||||
|
||||
|
|
@ -63,12 +64,12 @@ jobs:
|
|||
Description: High-performance GPU hardware screen recorder and instant replay overlay
|
||||
Depends: libgtk-3-0, libgtk-layer-shell0, libpipewire-0.3-0, libva2, libasound2
|
||||
EOF
|
||||
cp target/release/scythe-daemon "deb/scythe_${VERSION}/usr/bin/"
|
||||
cp target/release/scythe-ui "deb/scythe_${VERSION}/usr/bin/"
|
||||
ln -sf scythe-daemon "deb/scythe_${VERSION}/usr/bin/vrec-daemon"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec-ui"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/scythe"
|
||||
ln -sf scythe-ui "deb/scythe_${VERSION}/usr/bin/vrec"
|
||||
cp target/release/scythe "deb/scythe_${VERSION}/usr/bin/scythe"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/scythe-daemon"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/scythe-ui"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec-daemon"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec-ui"
|
||||
ln -sf scythe "deb/scythe_${VERSION}/usr/bin/vrec"
|
||||
cp packaging/scythe.desktop "deb/scythe_${VERSION}/usr/share/applications/"
|
||||
dpkg-deb --build "deb/scythe_${VERSION}" "dist/scythe_${VERSION}_amd64.deb"
|
||||
|
||||
|
|
@ -81,17 +82,18 @@ jobs:
|
|||
- name: Package Standalone AppImage
|
||||
run: |
|
||||
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/scalable/apps
|
||||
cp target/release/scythe-ui AppDir/usr/bin/
|
||||
cp target/release/scythe-daemon AppDir/usr/bin/
|
||||
ln -sf scythe-ui AppDir/usr/bin/scythe
|
||||
ln -sf scythe-ui AppDir/usr/bin/vrec-ui
|
||||
ln -sf scythe-daemon AppDir/usr/bin/vrec-daemon
|
||||
cp target/release/scythe AppDir/usr/bin/
|
||||
ln -sf scythe AppDir/usr/bin/scythe-ui
|
||||
ln -sf scythe AppDir/usr/bin/scythe-daemon
|
||||
ln -sf scythe AppDir/usr/bin/vrec-ui
|
||||
ln -sf scythe AppDir/usr/bin/vrec-daemon
|
||||
ln -sf scythe AppDir/usr/bin/vrec
|
||||
cp packaging/scythe.desktop AppDir/usr/share/applications/
|
||||
|
||||
cat > AppDir/AppRun << 'EOF'
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
||||
exec "${HERE}/usr/bin/scythe-ui" "$@"
|
||||
exec "${HERE}/usr/bin/scythe" "$@"
|
||||
EOF
|
||||
chmod +x AppDir/AppRun
|
||||
|
||||
|
|
@ -140,8 +142,7 @@ jobs:
|
|||
run: |
|
||||
cargo build --release
|
||||
mkdir -p dist/bundle
|
||||
cp target/release/scythe-daemon.exe dist/bundle/
|
||||
cp target/release/scythe-ui.exe dist/bundle/
|
||||
cp target/release/scythe.exe dist/bundle/
|
||||
python3 packaging/windows/bundle_deps.py dist/bundle
|
||||
strip dist/bundle/*.exe || true
|
||||
strip --strip-unneeded dist/bundle/*.dll || true
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ cache:
|
|||
- target/
|
||||
|
||||
# ---------- check ----------
|
||||
# The codebase is not rustfmt-clean yet; this job reports drift without blocking
|
||||
# builds. Run `cargo fmt` in a dedicated commit before promoting it to a hard gate.
|
||||
fmt:
|
||||
stage: check
|
||||
image: rust:latest
|
||||
|
|
@ -25,6 +27,7 @@ fmt:
|
|||
- cargo fmt --check
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
allow_failure: true
|
||||
|
||||
clippy:
|
||||
stage: check
|
||||
|
|
@ -52,7 +55,6 @@ test:
|
|||
build-linux:
|
||||
stage: build
|
||||
image: rust:latest
|
||||
needs: [fmt]
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y
|
||||
pkg-config libssl-dev
|
||||
|
|
@ -65,8 +67,7 @@ build-linux:
|
|||
artifacts:
|
||||
name: "scythe-linux-x86_64"
|
||||
paths:
|
||||
- target/release/scythe-daemon
|
||||
- target/release/scythe-ui
|
||||
- target/release/scythe
|
||||
expire_in: 30 days
|
||||
|
||||
build-windows:
|
||||
|
|
@ -79,12 +80,11 @@ build-windows:
|
|||
gcc-mingw-w64-x86-64
|
||||
script:
|
||||
- rustup target add x86_64-pc-windows-gnu
|
||||
- cargo build --release --locked --target x86_64-pc-windows-gnu
|
||||
--bin scythe-daemon --bin scythe-ui
|
||||
- cargo build --release --locked --target x86_64-pc-windows-gnu --bin scythe
|
||||
artifacts:
|
||||
name: "scythe-windows-x86_64"
|
||||
paths:
|
||||
- target/x86_64-pc-windows-gnu/release/*.exe
|
||||
- target/x86_64-pc-windows-gnu/release/scythe.exe
|
||||
expire_in: 30 days
|
||||
|
||||
# ---------- package ----------
|
||||
|
|
@ -101,6 +101,5 @@ release:
|
|||
artifacts:
|
||||
name: "scythe-$CI_COMMIT_TAG"
|
||||
paths:
|
||||
- target/release/scythe-daemon
|
||||
- target/release/scythe-ui
|
||||
- target/release/scythe
|
||||
expire_in: 90 days
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ version = "0.2.18"
|
|||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "scythe-daemon"
|
||||
path = "src/bin/scythe-daemon.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "scythe-ui"
|
||||
path = "src/bin/scythe-ui.rs"
|
||||
name = "scythe"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel = "0.5.16"
|
||||
|
|
@ -58,5 +54,6 @@ windows = { version = "0.58.0", features = [
|
|||
"Win32_System_Threading",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
|
|
|||
431
PROJECT_GUIDE.md
Normal file
431
PROJECT_GUIDE.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Scythe: Comprehensive Project & Architecture Guide
|
||||
|
||||
This document is the definitive technical reference for developers and AI agents working on the Scythe codebase. It outlines the project's design philosophy, unified binary architecture, hardware encoding pathways, audio subsystem, power management, HUD overlay, and development standards.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview & Current Status
|
||||
|
||||
**Current Version:** `v0.2.18`
|
||||
**License:** Dual MIT / Apache 2.0
|
||||
|
||||
### Repository Topology
|
||||
|
||||
| Remote | URL | Role | Status |
|
||||
|---|---|---|---|
|
||||
| `forgejo` | `https://git.oiupoyt.space/oiupoyt/scythe.git` | Self-hosted primary; source of truth, release assets | Active |
|
||||
| `origin` | `https://spearzificial@github.com/spearzificial/scythe.git` | **GitHub development mirror** (currently maintained account) | Active |
|
||||
| `gitlab` | `https://gitlab.com/oiupoyt.cat/scythe.git` | Optional mirror | Optional |
|
||||
|
||||
**GitHub account policy:** GitHub pushes go to the `spearzificial` account, and `origin` points at that account's repository. The `oiupoyt` GitHub OAuth token is expired, so the old `oiupoyt` repository is not a push target. Always name the account in the remote URL (`https://spearzificial@github.com/...`) so Git selects the correct stored credential when multiple GitHub accounts exist in the credential store.
|
||||
|
||||
### Dual-Push Rule
|
||||
|
||||
Every push must reach both active remotes:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git push forgejo main
|
||||
```
|
||||
|
||||
|
||||
Scythe is a cross-platform, ultra-low-overhead desktop screen recorder, instant replay buffer, and livestreamer written in Rust. It delivers functionality comparable to NVIDIA ShadowPlay and GPU Screen Recorder with minimal CPU and GPU overhead.
|
||||
|
||||
### Core Capabilities:
|
||||
1. **Unified Multicall Binary Architecture**: UI overlay, CLI client, and headless daemon engine are consolidated into a single executable ([`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs)) with subcommands (`scythe daemon`, `scythe record`, `scythe save`, etc.) and backward-compatible symlinks (`scythe-daemon`, `scythe-ui`).
|
||||
2. **Zero-Copy Hardware Video Encoding**:
|
||||
- **Linux**: FFmpeg VA-API (`h264_vaapi`, `hevc_vaapi`, `av1_vaapi`) with direct DRM DMA-BUF import for Intel and AMD GPUs. Automatic low-power hardware fallback handles drivers lacking `VAEntrypointEncSliceLP`.
|
||||
- **Windows**: Native NVENC, AMD AMF, Intel QuickSync (QSV), and multithreaded software fallbacks via DirectX Graphics Infrastructure (DXGI) desktop duplication.
|
||||
3. **High-Fidelity Audio Engine**:
|
||||
- Multi-source routing: System Audio, Microphone, Both, or Muted.
|
||||
- Dual codec support: Native Opus (`AV_CODEC_ID_OPUS`, 48 kHz stereo, 160 kbps, `AV_SAMPLE_FMT_FLT`) as recommended default, with native AAC (`AV_CODEC_ID_AAC`, 48 kHz stereo, 192 kbps, `AV_SAMPLE_FMT_FLTP`) as universal fallback.
|
||||
- Interactive sound verification suite with live dB / percentage VU meters and test tone playback.
|
||||
4. **Lock-Free Circular RAM Replay Buffer**:
|
||||
- Continuous lock-free packet ring buffer in system RAM (configurable from 5 seconds to 30 minutes).
|
||||
- Keyframe-aware draining ensures saved MP4 clips begin cleanly on IDR boundaries with zero decoder corruption.
|
||||
5. **Smart Power Management**:
|
||||
- AC power connectivity monitoring via Linux `/sys/class/power_supply` and Windows Win32 `GetSystemPowerStatus`.
|
||||
- Dedicated 1-second background power poller thread.
|
||||
- "Power-only" replay mode automatically drains and pauses the replay buffer on battery power to conserve battery life, and seamlessly resumes with keyframe forced resync upon AC connection.
|
||||
6. **KAT Square Minimalist Interface**:
|
||||
- Strictly conforms to `DESIGN.md` guidelines: `border-radius: 0 !important;` across all windows, inputs, buttons, and badges.
|
||||
- Obsidian dark palette (`#0b0b0b` / `#111111`) with hairline `#222222` framing and restrained functional accents.
|
||||
- Borderless, shadowless terminal-style notification toasts.
|
||||
- Monospace typography option with integrated `Adwaita Mono` font.
|
||||
7. **Screenshot Resolution Presets**:
|
||||
- Intuitive presets: `Native (Source)`, `1080p (FHD)`, `1440p (QHD)`, `2160p (4K UHD)`, `720p (HD)` with hardware-accelerated Lanczos scaling via FFmpeg.
|
||||
8. **In-App Auto-Updater**:
|
||||
- Synchronizes against Forgejo releases API (`https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`) with automated binary replacement.
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture & Process Model
|
||||
|
||||
Scythe uses a unified multicall binary dispatch model:
|
||||
|
||||
```
|
||||
+-----------------------------------+
|
||||
| scythe binary |
|
||||
+-----------------+-----------------+
|
||||
|
|
||||
Inspects argv[0] and first CLI argument
|
||||
|
|
||||
+---------------------------------+---------------------------------+
|
||||
| |
|
||||
v v
|
||||
"scythe daemon" "scythe [command]"
|
||||
or argv[0] == "scythe-daemon" or argv[0] == "scythe-ui"
|
||||
| |
|
||||
+--------v-------------------------+ +---------v-----------------------+
|
||||
| Headless Daemon Engine | | Client CLI & Overlay |
|
||||
| (src/daemon.rs) | <--- Unix Domain Socket / --- | (src/overlay_egui.rs) |
|
||||
| - Screen Capture (PipeWire/DXGI) | Local TCP IPC Loop | - egui HUD Overlay (Alt+Z) |
|
||||
| - Hardware Video Encoder | | - Audio Diagnostics & VU Meters |
|
||||
| - Native Opus/AAC Audio Encoder | | - Clip Trimmer & Gallery |
|
||||
| - Lock-Free Circular Ring Buffer | | - Terminal Notification Toasts |
|
||||
| - AC Power Watcher Thread | | - Global Hotkey Listener Pump |
|
||||
| - MP4 Muxer & RTMP Streamer | +---------------------------------+
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
### Multicall Dispatching:
|
||||
- **Daemon Mode**: Invoked when `args[1] == "daemon"`, `args[1] == "--mock"`, or when executed via symlink `scythe-daemon` / `vrec-daemon`. Runs the headless capture and recording engine ([`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs)).
|
||||
- **Dispatch uses argv[0], not `current_exe()`**: `current_exe()` reads `/proc/self/exe`, which resolves symlinks to the real file. Every installed alias would therefore report `scythe` and dispatch to the client, so `scythe-daemon` would open the HUD instead of starting the engine. `invoked_stem()` reads argv[0] and falls back to `current_exe()` only when argv[0] has no usable stem.
|
||||
- **Client / UI Mode**: Invoked when executed as `scythe`, `scythe-ui`, or with client arguments (`save`, `record`, `screenshot`, `status`, `menu`, etc.). Runs [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and launches [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
|
||||
---
|
||||
|
||||
## 3. Inter-Process Communication (IPC)
|
||||
|
||||
The protocol is implemented in [`src/ipc.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ipc.rs).
|
||||
|
||||
### Transport Layer:
|
||||
- **Linux**: Unix Domain Socket at `$XDG_RUNTIME_DIR/scythe.sock` (with legacy fallback to `/run/user/<UID>/vrec.sock`).
|
||||
- **Windows**: Local TCP socket on `127.0.0.1` binding to candidate ports `42069`–`42072` and persisting the active port in `%TEMP%\scythe-ipc.port`.
|
||||
|
||||
### Message Payload:
|
||||
Packets are framed with a 4-byte little-endian length prefix followed by JSON-serialized structs:
|
||||
- **Command (`Command`)**:
|
||||
- `SaveReplay`: Drains the circular buffer and muxes an MP4 replay.
|
||||
- `StartRecording` / `StopRecording` / `ToggleRecording`: Controls manual recording.
|
||||
- `StartStreaming` / `StopStreaming` / `ToggleStreaming`: Controls RTMP broadcasting.
|
||||
- `ToggleAudio`: Toggles microphone/system audio mute state.
|
||||
- `CycleAudioMode`: Cycles through System -> Mic -> Both -> Muted.
|
||||
- `ToggleCursor`: Inverts cursor visibility state and triggers capture synchronization.
|
||||
- `ShowOverlay`: Opens the HUD overlay via the daemon if it is not already running (`scythe --show-overlay`). Distinct from `--menu`, which toggles an already-open overlay closed.
|
||||
- `ReloadConfig`: Live-reloads configuration changes without restarting the daemon unless video parameters change.
|
||||
- `PowerStateChanged(bool)`: Internal notification dispatched by the power poller when AC state transitions.
|
||||
- `GetStatus`: Requests the current `DaemonStatus`.
|
||||
- `StopDaemon`: Triggers clean daemon shutdown.
|
||||
- **DaemonStatus (`DaemonStatus`)**:
|
||||
- `is_recording`: bool
|
||||
- `recording_duration_sec`: u64
|
||||
- `is_replay_active`: bool
|
||||
- `audio_muted`: bool
|
||||
- `audio_mode`: String ("system", "mic", "both", "muted")
|
||||
- `show_cursor`: bool
|
||||
- `mic_volume` / `system_volume`: f32
|
||||
- `mic_level_peak` / `system_level_peak`: f32 (0.0 to 1.0 real-time peak values)
|
||||
- `is_streaming`: bool
|
||||
- `streaming_duration_sec`: u64
|
||||
|
||||
---
|
||||
|
||||
## 4. Video Capture Subsystem
|
||||
|
||||
Capture sources implement the [`FrameSource`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mod.rs#L21) trait located in [`src/capture/mod.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mod.rs).
|
||||
|
||||
### Frame Representation:
|
||||
```rust
|
||||
pub enum Frame {
|
||||
Raw {
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
data: Arc<Vec<u8>>,
|
||||
},
|
||||
DmaBuf {
|
||||
fd: std::os::fd::RawFd,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
format: u32,
|
||||
modifier: u64,
|
||||
offset: u32,
|
||||
},
|
||||
D3D11Texture {
|
||||
handle: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Implementations:
|
||||
1. **Wayland ([`src/capture/wayland.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland.rs) & [`src/capture/wayland_stream.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/wayland_stream.rs))**:
|
||||
- Uses `xdg-desktop-portal` and PipeWire screencast stream.
|
||||
- Negotiates DMA-BUF memory buffers (`SPA_DATA_DmaBuf`) for zero-copy GPU video encoding.
|
||||
- Falls back to shared memory buffers (`SPA_DATA_MemPtr`) when DMA-BUF is unavailable.
|
||||
2. **X11 ([`src/capture/x11.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/x11.rs))**:
|
||||
- Uses MIT-SHM (`xcb::shm`) for direct shared-memory frame transfer from the X server.
|
||||
3. **Windows ([`src/capture/windows.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/windows.rs))**:
|
||||
- Uses the DirectX Graphics Infrastructure (DXGI) Desktop Duplication API (`IDXGIOutputDuplication`).
|
||||
- Iterates through all display adapters and outputs to probe and select the active desktop.
|
||||
- Uses a single Direct3D 11 staging texture (`D3D11_USAGE_STAGING`) with `D3D11_CPU_ACCESS_READ` for low-latency GPU readback.
|
||||
- Tightly packs rows into BGRA format if GPU pitch padding is detected.
|
||||
- Ensures fallback / idle frames generate opaque black (`[0, 0, 0, 255]`) to prevent uninitialized green chroma artifacts.
|
||||
4. **Mock Capture ([`src/capture/mock.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/mock.rs))**:
|
||||
- Generates simulated 1080p frames for headless unit tests and CI runners where no display server is attached.
|
||||
|
||||
---
|
||||
|
||||
## 5. Video Encoding Pipeline
|
||||
|
||||
The video encoder module is located in [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs).
|
||||
|
||||
### Linux Encoder (`VaapiEncoder`):
|
||||
- Uses FFmpeg libavcodec with VA-API hardware acceleration (`h264_vaapi`, `hevc_vaapi`, `av1_vaapi`).
|
||||
- Direct DRM DMA-BUF Import: Zero-copy surface creation via `vaCreateSurfacesFromProperties` for Intel and AMD GPUs.
|
||||
- Color Standards: ITU-R BT.709 color primaries (`AVCOL_PRI_BT709`), transfer characteristics (`AVCOL_TRC_BT709`), and MPEG limited range (`AVCOL_RANGE_MPEG`).
|
||||
- SIMD Color Space Conversion: When software fallback is required, `sws_scale` operates with `SWS_FAST_BILINEAR` and explicit ITU-709 coefficient mapping.
|
||||
|
||||
### Windows Encoder (`WindowsHwEncoder`):
|
||||
- Hardware Probing Priority:
|
||||
- H.264: `h264_nvenc` -> `h264_amf` -> `h264_qsv` -> `libx264`
|
||||
- HEVC: `hevc_nvenc` -> `hevc_amf` -> `hevc_qsv` -> `libx265`
|
||||
- AV1: `av1_nvenc` -> `av1_amf` -> `av1_qsv` -> `libsvtav1`
|
||||
- Standardized Pixel Format: NV12 (`AV_PIX_FMT_NV12`) across hardware encoders, with YUV420P fallback.
|
||||
- Color Conversion: Converts captured BGRA to NV12 using `sws_scale` configured with ITU-R BT.709 matrix and full-range to limited-range mapping (`sws_setColorspaceDetails`), eliminating washed-out colors and green screen bugs.
|
||||
- Low Latency Settings: Rate control VBR with burst ceiling, zero B-frames (`max_b_frames = 0`), ultrafast tuning, and 1-second GOP size (`gop_size = fps`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Audio Subsystem
|
||||
|
||||
Audio capture and conversion is implemented in [`src/capture/audio.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/capture/audio.rs) and [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs).
|
||||
|
||||
### Pipeline:
|
||||
1. **Capture**:
|
||||
- **Linux**: Uses PulseAudio/PipeWire `parec` targeting `@DEFAULT_MONITOR@` (for system audio loopback) and `@DEFAULT_SOURCE@` (for microphone input) with a 20ms low-latency buffer.
|
||||
- **Windows**: Direct WASAPI loopback capture using the `cpal` crate.
|
||||
2. **Channel Downmixing & Resampling**:
|
||||
- Downmixes 5.1 and 7.1 surround sound to stereo using center and surround coefficients:
|
||||
```
|
||||
L = FL + 0.707 * C + 0.707 * SL + 0.5 * LFE
|
||||
R = FR + 0.707 * C + 0.707 * SR + 0.5 * LFE
|
||||
```
|
||||
- Resamples arbitrary input sample rates to 48,000 Hz using linear interpolation and soft-clipping limiting (`tanh` curve).
|
||||
3. **Encoding (`AudioEncoder`)**:
|
||||
- **Opus (`AV_CODEC_ID_OPUS`)**: Native Opus stereo at 160 kbps, 48 kHz with float sample format (`AV_SAMPLE_FMT_FLT`). Primary recommended standard.
|
||||
- **AAC (`AV_CODEC_ID_AAC`)**: Native AAC stereo at 192 kbps, 48 kHz with planar float format (`AV_SAMPLE_FMT_FLTP`). Universal fallback standard.
|
||||
- Microsecond-accurate PTS rebasing aligns audio packets against video IDR keyframes.
|
||||
4. **Interactive Verification & Live VU Meters**:
|
||||
- The UI includes live decibel / percentage VU meters for both system sound and microphone.
|
||||
- Integrated test suite (`test mic` and `test sys` with automated chime playback) allows users to immediately verify sound capture without running external applications.
|
||||
|
||||
---
|
||||
|
||||
## 7. Lock-Free RAM Ring Buffer & Power Management
|
||||
|
||||
Implemented in [`src/ring.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ring.rs), [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs), and [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs).
|
||||
|
||||
### Buffer Mechanics:
|
||||
- Keeps encoded video and audio packets in RAM without writing to disk during active buffer mode.
|
||||
- Fixed Capacity: Capacity is calculated dynamically from duration (seconds) * target framerate * safety factor. On low-power hardware, capacity is tightly bounded to conserve memory.
|
||||
- **Keyframe-Aware Pruning**:
|
||||
- Replay clips must always begin with an IDR keyframe to prevent visual corruption.
|
||||
- When the circular buffer reaches maximum capacity, packets are dropped starting from the oldest non-keyframe sequence up to the next IDR keyframe boundary.
|
||||
- Drain Operation: When `Command::SaveReplay` is received, the buffer packets are cloned into an asynchronous channel without blocking the capture and encoding threads.
|
||||
|
||||
### AC Power Management:
|
||||
- **Connectivity Detection**: `ScytheConfig::is_ac_power_connected()` inspects `/sys/class/power_supply/*/online` on Linux and calls `GetSystemPowerStatus` on Windows.
|
||||
- **Poller Thread**: A dedicated 1-second background thread continuously monitors AC power changes.
|
||||
- **Automatic Buffer Gating**: When `autostart_power_only` is enabled:
|
||||
- On battery power loss: The replay buffer is automatically paused, drained of existing frames, and a toast notification (`power lost, replay paused`) is dispatched.
|
||||
- On AC connection restore: Replay buffering automatically resumes, forces an encoder keyframe, and dispatches a notification (`power restored, replay resumed`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Muxer & Livestreaming
|
||||
|
||||
Implemented in [`src/muxer.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/muxer.rs).
|
||||
|
||||
### MP4 Container Muxing (`Muxer`):
|
||||
- Wraps FFmpeg libavformat to produce universal MP4 files (`.mp4`) containing H.264/HEVC/AV1 video and Opus/AAC audio.
|
||||
- Emits faststart headers (`+faststart`) to place the `moov` atom at the beginning of the file for instant streaming and playback.
|
||||
- Rebases PTS and DTS timestamps to zero for both video and audio streams at file initialization.
|
||||
- Automatic Collision Handling: Formats filenames as `Replay-HH-MM-SS_DD-MM-YYYY.mp4` or `Recording-HH-MM-SS_DD-MM-YYYY.mp4` and resolves conflicts via incremental suffixes (`_1`, `_2`).
|
||||
|
||||
### RTMP Livestreaming (`spawn_stream_worker`):
|
||||
- Connects to RTMP endpoints (Twitch, YouTube, Kick, Custom RTMP).
|
||||
- Encapsulates video and audio NAL units into FLV/RTMP transport on a dedicated worker thread with non-blocking channel dispatch.
|
||||
|
||||
---
|
||||
|
||||
## 9. In-Game HUD Overlay & Notifications
|
||||
|
||||
Implemented in [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
|
||||
### Design System: KAT Square Minimalist:
|
||||
- Strictly adheres to `DESIGN.md`:
|
||||
- **`border-radius: 0 !important;`**: Zero rounded corners or pill badges anywhere in the interface.
|
||||
- **Obsidian Dark Palette**: `#0b0b0b` root background, `#111111` surface panels, `#222222` hairline borders.
|
||||
- **Restrained Accents**: Functional emerald (`#10b981`), red (`#ef4444`), cyan (`#38bdf8`), and vendor themes (AMD, NVIDIA, Intel).
|
||||
- **Monospace Typography**: Full support for `Adwaita Mono` / `JetBrains Mono` with an interface toggle.
|
||||
|
||||
### Views:
|
||||
- **Main HUD**:
|
||||
- Rolldown / swipe animated dropdown menus (`ui.ctx().animate_bool(...)` over 180ms) for Replay, Record, and Broadcast.
|
||||
- Quick actions with drop shadow framing.
|
||||
- **Settings View**:
|
||||
- File Information: Output directory with native folder pickers.
|
||||
- Video Parameters: Resolution scaling, FPS (15 to 360), bitrate (5 to 150 Mbps), codec (H.264, HEVC, AV1), color range (Full vs Limited), low-power recording mode.
|
||||
- Audio: Mode routing, volume sliders (0-150%), Opus/AAC codec choice, and live verification test suite.
|
||||
- Screenshot: Resolution presets (`Native`, `1080p`, `1440p`, `2160p`, `720p`), format (`PNG` vs `JPEG`), and cursor capture toggle.
|
||||
- General: Theme accents, 8 global keybinds with interactive recording listener, compact `Reset Hotkeys` button, and typography toggle.
|
||||
- **Notification Toasts (`ShadowPlayToastApp`)**:
|
||||
- Borderless, shadowless terminal-style card.
|
||||
- Obsidian `#111111` fill with 2px vertical accent rule and 6px status dot.
|
||||
- Uppercase monospace title, lowercase metadata subtitle, and 2px hairline progress countdown line.
|
||||
|
||||
---
|
||||
|
||||
## 10. Global Hotkeys & Window Hooks
|
||||
|
||||
Implemented in [`src/hotkey.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/hotkey.rs) and [`src/hyprland_binds.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/hyprland_binds.rs).
|
||||
|
||||
### Keybind Registry:
|
||||
1. Menu Overlay: `Alt+Z`
|
||||
2. Save Instant Replay: `Ctrl+Shift+R`
|
||||
3. Start / Stop Recording: `Ctrl+Shift+F9`
|
||||
4. Pause / Resume Recording: `Ctrl+Shift+F8`
|
||||
5. Take Screenshot: `Ctrl+Shift+S`
|
||||
6. Toggle Mouse Cursor: `Ctrl+Shift+F10`
|
||||
7. Mute / Unmute Microphone: `Ctrl+Shift+M`
|
||||
8. Mute / Unmute System Audio: `Ctrl+Shift+N`
|
||||
|
||||
### Dispatch Strategies:
|
||||
1. **Hyprland Direct IPC**: Registers binds dynamically using `hyprctl keyword bind` to bypass Wayland global hotkey restrictions. Watches `hyprland.conf` modifications and auto-reapplies.
|
||||
2. **Universal Desktop**: Uses the `global-hotkey` crate for standard Windows and X11 hotkey registration.
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration Management
|
||||
|
||||
Implemented in [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs).
|
||||
|
||||
### File Locations:
|
||||
- Linux: `~/.config/scythe/config.json`
|
||||
- Windows: `%APPDATA%\scythe\config.json`
|
||||
|
||||
### Autostart Integration:
|
||||
`ScytheConfig::apply_autostart_system(enable_replay, enable_overlay)` manages:
|
||||
1. Systemd user service: `systemctl --user enable/disable scythe-daemon.service`.
|
||||
2. Standard XDG autostart desktop entries in `~/.config/autostart/scythe-daemon.desktop` and `scythe-overlay.desktop`.
|
||||
|
||||
Linux only. The Windows path is a no-op: there is no registry `Run` key write in either the overlay toggle or the NSIS installer, so the autostart switch in the UI currently has no effect on Windows.
|
||||
|
||||
### Retired Settings (v0.2.18 cleanup):
|
||||
These keys were accepted by the settings UI and persisted to disk, but nothing in the capture, encoder, or daemon paths ever read them. They were removed from `ScytheConfig` and the corresponding UI controls were deleted, so the overlay no longer presents options that do nothing:
|
||||
|
||||
| Retired key | Why it was removed |
|
||||
|---|---|
|
||||
| `capture_source` | Capture always selected the focused monitor; window/region modes were never implemented |
|
||||
| `replay_storage` | Replay buffer is always RAM-backed; the disk path was never built |
|
||||
| `change_resolution`, `resolution_w`, `resolution_h` | Encoder always used native capture dimensions |
|
||||
| `container_format` | Muxer always writes MP4 |
|
||||
| `show_scroll_lock_led` | No LED driver integration existed |
|
||||
| `autostart_on_game` | No game-detection layer existed |
|
||||
| `save_folder_by_game`, `name_by_game` | No game detection; filenames are timestamp-based |
|
||||
| `language`, `ui_color_theme` | No translation layer or theme engine; `accent_color` drives the real palette |
|
||||
| `record_enabled` | Dead field superseded by runtime `is_recording` state |
|
||||
|
||||
Unknown keys in an existing `config.json` are ignored by serde, so upgrading does not break saved configurations. `scythe` rewrites the file without these keys on the next save.
|
||||
|
||||
---
|
||||
|
||||
## 12. In-App Auto-Updater
|
||||
|
||||
Implemented in [`src/updater.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs).
|
||||
|
||||
### Workflow:
|
||||
1. Queries the self-hosted Forgejo release API:
|
||||
`GET https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`
|
||||
(Falls back to GitHub API if Forgejo is unreachable).
|
||||
2. Parses SemVer strings and checks if `is_newer(latest, CURRENT_VERSION)`.
|
||||
3. Downloads the singular platform package:
|
||||
- Linux: `scythe-v<version>-linux-x86_64.tar.gz`
|
||||
- Windows: `scythe-v<version>-windows-x86_64.zip` (includes standalone portable FFmpeg DLLs).
|
||||
4. Atomically swaps the executable in `~/.local/bin/scythe` or `%USERPROFILE%\.local\bin\scythe.exe`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Verification, Testing & Cross-Compilation
|
||||
|
||||
### Local Linux Commands:
|
||||
```bash
|
||||
# Type check all targets
|
||||
cargo check --all-targets
|
||||
|
||||
# Run the complete unit test suite (27 tests)
|
||||
cargo test
|
||||
|
||||
# Strict clippy lint verification
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Build optimized Linux release binary
|
||||
cargo build --release
|
||||
|
||||
# Install release binary and symlinks to ~/.local/bin
|
||||
bash install.sh
|
||||
|
||||
# Run end-to-end integration test suite
|
||||
bash tests/e2e_test.sh
|
||||
```
|
||||
|
||||
`tests/e2e_test.sh` treats a running daemon as a resource it borrows rather than deletes. Unix sockets cannot be re-linked: removing the path entry of a live daemon leaves the kernel socket bound but unreachable, and the session stays broken until that process dies.
|
||||
|
||||
The script therefore:
|
||||
1. probes liveness by connecting to the socket directly (`scythe --status` cannot be used for this, because it calls `ensure_daemon_running()` and would spawn a daemon while probing);
|
||||
2. records the running daemon's command line and working directory;
|
||||
3. sends `StopDaemon` and waits for the socket to disappear, aborting without unlinking if it will not stop;
|
||||
4. restores that daemon on exit, so a developer's recorder survives a test run.
|
||||
|
||||
### Windows Cross-Compilation (from Linux Host):
|
||||
```bash
|
||||
FFMPEG_DIR="$HOME/.local/share/ffmpeg-win64/ffmpeg-n9.0-latest-win64-gpl-shared-9.0" \
|
||||
PKG_CONFIG_PATH="$HOME/.local/share/ffmpeg-win64/ffmpeg-n9.0-latest-win64-gpl-shared-9.0/lib/pkgconfig" \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
### GitHub Actions:
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml) | push to `main`, pull request, manual | Compiles and tests both targets on every push. Linux job: `cargo check --all-targets`, `cargo test`, `cargo clippy -D warnings`, `cargo build --release`, then runs `scythe --help` to prove the binary starts. Windows job: MSYS2 MINGW64 `cargo build --release`. Both binaries upload as artifacts. |
|
||||
| [`release.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/release.yml) | push of a `v*` tag | Packages `.tar.gz`, `.deb`, `.rpm`, `.AppImage` and the NSIS Windows installer, then publishes a GitHub Release. |
|
||||
|
||||
The CI workflow is the only thing that compiles the Windows target on a routine basis, and it exists because the Windows build had already rotted: `src/config.rs` calls `GetSystemPowerStatus` but the `windows` crate feature list never enabled `Win32_System_Power`, so `cargo build --target x86_64-pc-windows-gnu` failed with `E0432`. Tag pushes were the only previous trigger, so nothing caught it before a release was attempted.
|
||||
|
||||
The `cargo fmt --check` step is advisory (`continue-on-error: true`). The tree has formatting drift that is intentionally not mass-fixed, because reformatting every file would bury the real changes in a diff.
|
||||
|
||||
Tests that need real hardware (a live audio server) are opt-in:
|
||||
|
||||
```bash
|
||||
SCYTHE_HW_TESTS=1 cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Behavioral Rules & Coding Standards
|
||||
|
||||
1. **ABSOLUTELY ZERO EMOJIS**: Under no circumstances should emojis be added to code, comments, log outputs, commit messages, or communication.
|
||||
2. **File & Symbol Links**: Every file path or code symbol mentioned in communication must be formatted as a clickable markdown link using the `file://` scheme (e.g., [`src/encoder.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs)).
|
||||
3. **KAT Square Minimalist**: Strict `border-radius: 0 !important;` on all visual elements.
|
||||
4. **Dual-Push Rule**: When pushing commits or tags, always push to both active remotes (`origin` for GitHub and `forgejo` for self-hosted):
|
||||
```bash
|
||||
git push origin <branch>
|
||||
git push forgejo <branch>
|
||||
```
|
||||
5. **Memory Safety & Process Isolation**: Avoid blocking the audio or video capture loops. Use unbounded or high-capacity crossbeam channels for frame dispatch.
|
||||
31
README.md
31
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> High-performance, zero-copy GPU hardware screen recorder and instant replay overlay for Linux (Wayland & X11) and Windows.
|
||||
|
||||
[](https://github.com/oiupoyt/scythe/releases)
|
||||
[](https://github.com/spearzificial/scythe/releases)
|
||||
[](LICENSE)
|
||||
|
||||
Scythe is a native, ultra-lightweight alternative to NVIDIA ShadowPlay and GPU Screen Recorder. It records gameplay and desktop screens with near-zero CPU and GPU overhead by leveraging hardware video acceleration and zero-copy direct memory pipelines.
|
||||
|
|
@ -35,10 +35,11 @@ Scythe is a native, ultra-lightweight alternative to NVIDIA ShadowPlay and GPU S
|
|||
|
||||
| Action | Shortcut | CLI Command |
|
||||
|---|---|---|
|
||||
| Open / Close HUD Overlay | `Alt + Z` | `scythe-ui --menu` |
|
||||
| Save Instant Replay | `Ctrl + Shift + R` | `scythe-ui --save` |
|
||||
| Toggle Normal Recording | `Ctrl + Shift + F9` | `scythe-ui --record` |
|
||||
| Toggle Mouse Cursor | `Ctrl + Shift + F10` | `scythe-ui --cursor` |
|
||||
| Open / Close HUD Overlay | `Alt + Z` | `scythe --menu` |
|
||||
| Save Instant Replay | `Ctrl + Shift + R` | `scythe --save` |
|
||||
| Toggle Normal Recording | `Ctrl + Shift + F9` | `scythe --record` |
|
||||
| Toggle Mouse Cursor | `Ctrl + Shift + F10` | `scythe --cursor` |
|
||||
| Take Screenshot | `Ctrl + Shift + S` | `scythe --screenshot` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ makepkg -si
|
|||
|
||||
### Pre-Built Packages (Releases)
|
||||
|
||||
Download the latest packages directly from [GitHub Releases](https://github.com/oiupoyt/scythe/releases):
|
||||
Download the latest packages directly from [GitHub Releases](https://github.com/spearzificial/scythe/releases) or [Forgejo Releases](https://git.oiupoyt.space/oiupoyt/scythe/releases):
|
||||
|
||||
- **Windows Setup**: `scythe-setup.exe` (Official installer with Start Menu & Desktop shortcuts)
|
||||
- **Windows Portable**: `scythe.exe` (Standalone self-contained executable) & `scythe-windows-x86_64.zip`
|
||||
|
|
@ -75,7 +76,7 @@ Download the latest packages directly from [GitHub Releases](https://github.com/
|
|||
#### Compile & Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiupoyt/scythe.git
|
||||
git clone https://github.com/spearzificial/scythe.git
|
||||
cd scythe
|
||||
bash install.sh
|
||||
```
|
||||
|
|
@ -85,13 +86,13 @@ bash install.sh
|
|||
## CLI Usage
|
||||
|
||||
```bash
|
||||
scythe-ui --menu # Open the interactive overlay UI menu
|
||||
scythe-ui --status # Query background daemon state and metrics
|
||||
scythe-ui --save # Save instant replay clip to disk
|
||||
scythe-ui --record # Toggle normal recording on/off
|
||||
scythe-ui --cursor # Toggle mouse cursor capture
|
||||
scythe-ui --reload # Reload daemon settings
|
||||
scythe-ui --quit # Stop background recording daemon
|
||||
scythe --menu # Open the interactive overlay UI menu
|
||||
scythe --status # Query background daemon state and metrics
|
||||
scythe --save # Save instant replay clip to disk
|
||||
scythe --record # Toggle normal recording on/off
|
||||
scythe --cursor # Toggle mouse cursor capture
|
||||
scythe --reload # Reload daemon settings
|
||||
scythe --quit # Stop background recording daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -106,7 +107,7 @@ Settings are saved in JSON format at `~/.config/scythe/config.json`:
|
|||
"record_bitrate_kbps": 18000,
|
||||
"replay_duration_sec": 60,
|
||||
"video_codec": "h264",
|
||||
"output_directory": "~/Videos/Scythe",
|
||||
"output_directory": "~/Videos",
|
||||
"show_cursor": true,
|
||||
"audio_mode": "system",
|
||||
"mic_volume": 0.6,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,20 @@
|
|||
|
||||
This checklist tracks known bugs, UI refinements, optimizations, and technical debt items across the Scythe codebase.
|
||||
|
||||
> **Historical note:** entries recorded before v0.2.18 reference `src/bin/scythe-daemon.rs` and `src/bin/scythe-ui.rs`. Those files were merged into [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs) by the multicall consolidation; the paths are kept as an accurate record of where the work happened at the time.
|
||||
|
||||
---
|
||||
|
||||
## 1. UI Refinements & Imperfections
|
||||
|
||||
- [x] **Singular Multicall Binary Architecture (`scythe`)**:
|
||||
- **Issue**: Distributing separate `scythe-daemon` and `scythe-ui` binaries required cross-process path discovery in `$PATH`, complicated Windows bundling with multiple executables, and prevented atomic single-file updates.
|
||||
- **Resolution**:
|
||||
- Consolidated daemon engine and UI overlay/CLI into a single binary (`scythe`) in [src/main.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and [src/daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs).
|
||||
- Implemented multicall dispatch: inspects `argv[0]` and subcommands (`scythe daemon`, `scythe [options]`, or symlinks `scythe-daemon` / `scythe-ui`).
|
||||
- Configured [install.sh](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh) with backward-compatible symlinks.
|
||||
- Updated release packages: both Linux and Windows distributions now ship a single executable.
|
||||
|
||||
- [x] **Screenshot Resolution Presets (Native, 1080p, 1440p, 2160p, 720p) & Lanczos Downscaling**:
|
||||
- **Issue**: Vague compression percentages and presets confused users instead of providing clear resolution controls.
|
||||
- **Resolution**: Replaced vague compression sliders with clean resolution presets (`Native (Source)`, `1080p (FHD)`, `1440p (QHD)`, `2160p (4K UHD)`, `720p (HD)`) in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). When a target resolution is selected, [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs) executes pristine Lanczos hardware-accelerated scaling via FFmpeg.
|
||||
|
|
@ -54,7 +64,7 @@ This checklist tracks known bugs, UI refinements, optimizations, and technical d
|
|||
- Completely stripped all roundness (`CornerRadius::ZERO`) from dropdown frames, dropdown items, keycap badges, and plus/minus stepper buttons in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs).
|
||||
- Removed all top horizontal accent bars from dropdown frames and added realistic drop shadows (`draw_window_shadow`).
|
||||
- Wired every single setting (`container_format`, `change_resolution`, `resolution_w`, `resolution_h`, `save_folder_by_game`, `name_by_game`, `restart_replay_on_save`, `show_scroll_lock_led`, `autostart_on_game`, `autostart_power_only`, `capture_source`, `video_quality_mode`, `color_range`, `audio_codec`, `replay_storage`, `record_low_power`, `show_replay_notifications`) to persist cleanly into [src/config.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs) and `~/.config/scythe/config.json`.
|
||||
- Added automated unit test `test_overlay_app_options_synchronization` (26/26 passing) verifying save, apply, and reset for every single option.
|
||||
- Added automated unit test `test_overlay_app_options_synchronization` (27/27 passing) verifying save, apply, and reset for every single option.
|
||||
- Fixed low-power hardware encoder fallback in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) on drivers without `VAEntrypointEncSliceLP`.
|
||||
|
||||
- [x] **Sharp Dark Slate Window Architecture with Drop Shadows (Matching GSR Screenshots)**:
|
||||
|
|
@ -140,6 +150,26 @@ This checklist tracks known bugs, UI refinements, optimizations, and technical d
|
|||
|
||||
## 3. Reliability & Bug Fixes
|
||||
|
||||
- [x] **Gray Screen Recording & Mock Fallback Startup Race Condition**:
|
||||
- **Issue**: At system boot or when logging into the session, `scythe-daemon` started before `xdg-desktop-portal` finished registering on D-Bus. After failing to acquire portals within 3 seconds, the daemon silently fell back to `MockCapture` (hardcoded to emit flat gray `RGB(64, 64, 64)` frames), permanently capturing a blank gray screen on subsequent recordings and replays.
|
||||
- **Resolution**:
|
||||
- Updated [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs) so that the daemon never silently traps the user into `MockCapture` unless the explicit `--mock` CLI flag is passed for automated testing.
|
||||
- Added systemd service dependencies (`After=graphical-session.target xdg-desktop-portal.service xdg-desktop-portal-hyprland.service` and `Wants=xdg-desktop-portal.service`) with `Restart=always` and `RestartSec=2` backoff in [install.sh](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh).
|
||||
|
||||
- [x] **GTK3 Retirement, Compile-Time Optimization & Windows Cross-Compilation**:
|
||||
- **Issue**: Legacy GTK3 C-bindings (`gtk`, `gdk`, `glib`, `gtk-layer-shell`) inflated compile times to over 1 minute, consumed heavy build memory, and completely blocked cross-compiling for Windows due to missing MinGW GTK headers.
|
||||
- **Resolution**:
|
||||
- Removed `gtk`, `gdk`, `glib`, `gtk-layer-shell`, and retired [src/settings_ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/settings_ui.rs) and the legacy GTK overlay code. Moved `ToastIcon` directly into [src/ipc.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/ipc.rs).
|
||||
- Reduced release compile time by over 60% and unlocked Windows release cross-compilation (`x86_64-pc-windows-gnu`).
|
||||
- Added optimized release profile with thin LTO and symbol stripping in [Cargo.toml](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/Cargo.toml).
|
||||
|
||||
- [x] **Forgejo Releases Integration & Auto-Updater Synchronization**:
|
||||
- **Issue**: The auto-updater was hardcoded to GitHub API endpoints, and release binaries were not packaged for both Windows and Linux on the self-hosted Forgejo instance.
|
||||
- **Resolution**:
|
||||
- Updated [src/updater.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/updater.rs) to prioritize Forgejo API releases (`https://git.oiupoyt.space/api/v1/repos/oiupoyt/scythe/releases/latest`) with GitHub fallback.
|
||||
- Cross-compiled release binaries for both Linux (`scythe-v0.2.18-linux-x86_64.tar.gz`) and Windows (`scythe-v0.2.18-windows-x86_64.zip` including full standalone portable FFmpeg DLLs).
|
||||
- Published Release `v0.2.18` directly to Forgejo with assets uploaded.
|
||||
|
||||
- [x] **Opus Audio Encoder Implementation & AAC Fallback**:
|
||||
- **Issue**: Previously, only AAC audio encoding was supported, despite Opus being the industry standard for low-latency streaming and high-fidelity game recording.
|
||||
- **Resolution**: Implemented native `Opus` audio encoding in [src/encoder.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/encoder.rs) (`AudioEncoder::new_with_codec`) using `AV_SAMPLE_FMT_FLT` at 160 kbps with automatic fallback to AAC when unavailable. Dynamically wired into [src/bin/scythe-daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-daemon.rs) based on `config.audio_codec`. Verified 60 FPS recording with native Opus audio streams in MP4 containers.
|
||||
|
|
@ -219,7 +249,74 @@ This checklist tracks known bugs, UI refinements, optimizations, and technical d
|
|||
|
||||
---
|
||||
|
||||
## 5. Verification Commands
|
||||
## 4a. Binary Dispatch
|
||||
|
||||
- [x] **Symlink Aliases Dispatched to the UI Instead of the Daemon**:
|
||||
- **Issue**: [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) decided daemon mode from `std::env::current_exe()`, which reads `/proc/self/exe` and resolves symlinks. Because every installed alias points at the real `scythe` file, all of them reported the stem `scythe`, so `scythe-daemon` and `vrec-daemon` fell through to client mode and opened the HUD instead of starting the recording engine. The aliases shipped by [`install.sh`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh), the `.deb`/`.rpm`/Flatpak packages, and the Hyprland `exec_cmd("scythe-daemon")` autostart line were all affected. A stale pre-consolidation `target/release/scythe-daemon` from an older build masked the bug during local testing.
|
||||
- **Resolution**: Added `invoked_stem()`, which reads argv[0] and only falls back to `current_exe()` when argv[0] has no usable stem. Verified against a packaged tarball: `scythe-daemon` and `vrec-daemon` now report the running-instance guard, while `scythe`, `scythe-ui`, `vrec` and `vrec-ui` open the client.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Release Pipeline & Packaging Consolidation
|
||||
|
||||
- [x] **CI/CD Referenced Binaries That No Longer Build**:
|
||||
- **Issue**: The multicall consolidation removed `scythe-daemon` and `scythe-ui` crate targets, but the release pipeline still copied `target/release/scythe-daemon` and `target/release/scythe-ui`. A tag push would fail at the very first packaging step, so no release could be produced. The same stale paths existed in the GitLab CI artifacts, RPM spec, Flatpak manifest, and both NSIS installers.
|
||||
- **Resolution**:
|
||||
- [`.github/workflows/release.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/release.yml): tarball, `.deb`, and AppImage now ship the single `scythe` binary plus `scythe-daemon` / `scythe-ui` / `vrec-*` symlinks. The AppImage `AppRun` and the Windows bundle copy only `scythe` / `scythe.exe`.
|
||||
- [`.gitlab-ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.gitlab-ci.yml): artifacts publish `target/release/scythe`; the Windows job builds `--bin scythe`.
|
||||
- [`packaging/rpm/scythe.spec`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/rpm/scythe.spec), [`packaging/flatpak/com.scythe.Scythe.yaml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/flatpak/com.scythe.Scythe.yaml): install one binary, link the aliases; Flatpak `command` is now `scythe`.
|
||||
- [`packaging/windows/scythe-installer.nsi`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/windows/scythe-installer.nsi), [`packaging/windows/scythe-standalone.nsi`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/packaging/windows/scythe-standalone.nsi): shortcuts, finish-page runner, `DisplayIcon`, and forwarded arguments all target `scythe.exe`. Legacy process names remain in the `taskkill` upgrade path so old installs shut down cleanly.
|
||||
|
||||
- [x] **GitLab `fmt` Job Blocked Every Build**:
|
||||
- **Issue**: `cargo fmt --check` reports drift across 20 files, and `build-linux` declared `needs: [fmt]`, so a formatting mismatch prevented any build from running.
|
||||
- **Resolution**: Marked the `fmt` job `allow_failure: true` and dropped the hard `needs` edge, matching how `clippy` is already treated. A whole-tree reformat belongs in its own commit rather than being mixed into functional changes.
|
||||
|
||||
- [x] **GitHub Push Target & Credentials**:
|
||||
- **Issue**: GitHub pushes targeted `oiupoyt/scythe`, but that account's OAuth token is expired, so the mirror had fallen 31 commits behind and pushes failed.
|
||||
- **Resolution**: Added the `spearzificial` remote and pinned the username in the URL (`https://spearzificial@github.com/spearzificial/scythe.git`) so Git selects the correct entry from the multi-account credential store. Repository topology and the dual-push rule are documented in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md).
|
||||
|
||||
---
|
||||
|
||||
## 4c. Dead Configuration Removal
|
||||
|
||||
- [x] **Settings That Persisted But Did Nothing**:
|
||||
- **Issue**: Fourteen `ScytheConfig` keys were exposed in the settings UI and written to `config.json`, but no capture, encoder, muxer, or daemon path ever read them. Users could change them with no observable effect.
|
||||
- **Resolution**: Removed the keys from [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs), deleted their overlay state fields and controls in [`src/overlay_egui.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs), and pruned the assertions in both test suites. The retired list and rationale live in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md). Unknown JSON keys are ignored by serde, so existing configs still load.
|
||||
|
||||
- [x] **`Command::ShowOverlay` Was Declared But Ignored**:
|
||||
- **Issue**: The IPC enum defined `ShowOverlay`, but the daemon's dispatcher had no arm for it and it fell through to `_ => {}`. Nothing could open the HUD once closed except the `--menu` toggle path.
|
||||
- **Resolution**: Added `show_overlay_once()` in [`src/daemon.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs) — it checks the overlay pid file for a live process and spawns `scythe --menu` only when none is running, so repeated requests cannot stack overlays. Exposed as `scythe --show-overlay`, with a direct-launch fallback when the daemon is unreachable.
|
||||
|
||||
- [x] **E2E Test Unlinked a Live Daemon's Socket**:
|
||||
- **Issue**: [`tests/e2e_test.sh`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/tests/e2e_test.sh) ran `rm -f "$SOCKET_PATH"` unconditionally and only stopped the systemd unit. A daemon started any other way (Hyprland `execs.lua`, manual launch) kept the flock; the test removed its socket path entry, leaving the kernel socket bound but unreachable. The session stayed broken until the process was killed.
|
||||
- **Resolution**: The script now probes liveness with a direct Unix-socket connect (not `--status`, which calls `ensure_daemon_running()` and spawns a daemon while probing), records the running daemon's command line and working directory, sends `StopDaemon`, waits for the socket to disappear, aborts without unlinking if it will not stop, and restores the original daemon on exit.
|
||||
|
||||
---
|
||||
|
||||
## 4d. Build & CI
|
||||
|
||||
- [x] **No CI Ran on Routine Commits**:
|
||||
- **Issue**: GitHub Actions only triggered on `v*` tag pushes. Nothing compiled the project between releases, so breakage accumulated unnoticed and every release was the first real build in weeks.
|
||||
- **Resolution**: Added [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml), which runs on every push to `main`, on pull requests, and manually. The Linux job type-checks, tests, lints with `-D warnings`, builds the release binary, runs `scythe --help` to prove the binary starts, and uploads it as an artifact. The Windows job builds through MSYS2 MINGW64 and uploads `scythe.exe`. Both binaries are downloadable from the run.
|
||||
|
||||
- [x] **The Windows Target Did Not Compile**:
|
||||
- **Issue**: [`src/config.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/config.rs) reads AC line status through `GetSystemPowerStatus`, but the `windows` crate feature list in [`Cargo.toml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/Cargo.toml) never enabled `Win32_System_Power`. `cargo build --target x86_64-pc-windows-gnu` failed with `E0432: unresolved import windows::Win32::System::Power`. The build had been broken since the power-monitoring feature landed, and only tag pushes could have revealed it.
|
||||
- **Resolution**: Added the `Win32_System_Power` feature. The Windows job now builds green on every push.
|
||||
|
||||
- [x] **Unknown CLI Arguments Became a Background Hotkey Daemon**:
|
||||
- **Issue**: The dispatch match in [`src/main.rs`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) ended in `_ => {}`, so an unrecognised flag fell through into the global hotkey loop. `scythe --version` registered the Hyprland binds and then blocked forever instead of reporting the bad flag.
|
||||
- **Resolution**: The catch-all arm now prints the offending argument with a pointer to `--help` and exits with status 2.
|
||||
|
||||
- [x] **The Audio Test Needed Hardware CI Could Not Provide**:
|
||||
- **Issue**: `test_audio_probe` asserts that a system audio device opens and emits samples. A headless runner has no audio server, so the unit suite could never pass in CI.
|
||||
- **Resolution**: The test is opt-in through `SCYTHE_HW_TESTS=1` and still runs on a real desktop session. The remaining 27 tests run everywhere.
|
||||
|
||||
- [x] **Windows Autostart Does Nothing**:
|
||||
- **Issue**: `apply_autostart_system` has only a Linux body (systemd user unit plus XDG autostart entries). On Windows the call is a no-op, and the NSIS installer writes no registry `Run` key either, so the autostart switch in the overlay has no effect there.
|
||||
- **Resolution**: Parameters marked intentionally unused off Linux so the Windows build is warning-free, and the gap is recorded in [`PROJECT_GUIDE.md`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/PROJECT_GUIDE.md). Implementing the registry entry is still open.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Run these commands before any commit or release to verify project health:
|
||||
|
||||
|
|
@ -227,12 +324,22 @@ Run these commands before any commit or release to verify project health:
|
|||
# Type check all platform targets
|
||||
cargo check --all-targets
|
||||
|
||||
# Run the 21-test automated suite
|
||||
# Run the complete unit test suite (27 tests)
|
||||
cargo test
|
||||
|
||||
# Strict zero-warning clippy verification
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Build release binaries
|
||||
# Build the unified release binary
|
||||
cargo build --release
|
||||
|
||||
# Run the automated end-to-end pipeline test (mock capture, ffprobe validation)
|
||||
bash tests/e2e_test.sh
|
||||
|
||||
# Hardware-dependent tests (needs a live audio server)
|
||||
SCYTHE_HW_TESTS=1 cargo test
|
||||
```
|
||||
|
||||
GitHub Actions runs the same checks on every push to `main`, for both the Linux and the Windows target. See [`ci.yml`](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/.github/workflows/ci.yml).
|
||||
|
||||
Formatting is currently advisory: `cargo fmt --check` still reports drift across the tree. Run `cargo fmt` as an isolated commit before turning it into a release gate.
|
||||
|
|
|
|||
18
install.sh
18
install.sh
|
|
@ -15,15 +15,15 @@ APP_DIR="$HOME/.local/share/applications"
|
|||
mkdir -p "$BIN_DIR"
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
echo "[2/4] Installing binaries to $BIN_DIR..."
|
||||
install -m 755 target/release/scythe-daemon "$BIN_DIR/scythe-daemon"
|
||||
install -m 755 target/release/scythe-ui "$BIN_DIR/scythe-ui"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/scythe"
|
||||
echo "[2/4] Installing singular binary to $BIN_DIR..."
|
||||
install -m 755 target/release/scythe "$BIN_DIR/scythe"
|
||||
|
||||
# Backward-compatible symlinks
|
||||
ln -sf "$BIN_DIR/scythe-daemon" "$BIN_DIR/vrec-daemon"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec-ui"
|
||||
ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec"
|
||||
# Backward-compatible multicall symlinks
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-daemon"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-ui"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-daemon"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-ui"
|
||||
ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec"
|
||||
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
|
|
@ -35,7 +35,7 @@ Wants=xdg-desktop-portal.service
|
|||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$BIN_DIR/scythe-daemon
|
||||
ExecStart=$BIN_DIR/scythe daemon
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ runtime-version: '46'
|
|||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: scythe-ui
|
||||
command: scythe
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
|
|
@ -26,11 +26,11 @@ modules:
|
|||
buildsystem: simple
|
||||
build-commands:
|
||||
- cargo build --release --verbose
|
||||
- install -Dm755 target/release/scythe-daemon /app/bin/scythe-daemon
|
||||
- install -Dm755 target/release/scythe-ui /app/bin/scythe-ui
|
||||
- install -Dm755 target/release/scythe /app/bin/scythe
|
||||
- ln -sf /app/bin/scythe-daemon /app/bin/vrec-daemon
|
||||
- ln -sf /app/bin/scythe-ui /app/bin/vrec-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/scythe-daemon
|
||||
- ln -sf /app/bin/scythe /app/bin/scythe-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec-daemon
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec-ui
|
||||
- ln -sf /app/bin/scythe /app/bin/vrec
|
||||
- install -Dm644 packaging/scythe.desktop /app/share/applications/com.scythe.Scythe.desktop
|
||||
sources:
|
||||
|
|
|
|||
|
|
@ -32,19 +32,19 @@ cargo build --release
|
|||
|
||||
%install
|
||||
rm -rf $RPM_BUILD_ROOT
|
||||
install -D -m 0755 target/release/scythe-daemon %{buildroot}%{_bindir}/scythe-daemon
|
||||
install -D -m 0755 target/release/scythe-ui %{buildroot}%{_bindir}/scythe-ui
|
||||
install -D -m 0755 target/release/scythe %{buildroot}%{_bindir}/scythe
|
||||
ln -sf %{_bindir}/scythe-daemon %{buildroot}%{_bindir}/vrec-daemon
|
||||
ln -sf %{_bindir}/scythe-ui %{buildroot}%{_bindir}/vrec-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/scythe-daemon
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/scythe-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec-daemon
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec-ui
|
||||
ln -sf %{_bindir}/scythe %{buildroot}%{_bindir}/vrec
|
||||
install -D -m 0644 packaging/scythe.desktop %{buildroot}%{_datadir}/applications/scythe.desktop
|
||||
ln -sf %{_datadir}/applications/scythe.desktop %{buildroot}%{_datadir}/applications/vrec.desktop
|
||||
|
||||
%files
|
||||
%{_bindir}/scythe
|
||||
%{_bindir}/scythe-daemon
|
||||
%{_bindir}/scythe-ui
|
||||
%{_bindir}/scythe
|
||||
%{_bindir}/vrec-daemon
|
||||
%{_bindir}/vrec-ui
|
||||
%{_bindir}/vrec
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ RequestExecutionLevel user
|
|||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\scythe-ui.exe"
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\scythe.exe"
|
||||
!define MUI_FINISHPAGE_RUN_PARAMETERS "--menu"
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Launch Scythe"
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
|
@ -27,11 +27,12 @@ RequestExecutionLevel user
|
|||
|
||||
Section "Scythe" SecMain
|
||||
; Terminate any running instances so locked executables and DLLs can be updated cleanly
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
SetOverwrite try
|
||||
|
|
@ -40,23 +41,24 @@ Section "Scythe" SecMain
|
|||
|
||||
; Create Start Menu and Desktop shortcuts
|
||||
CreateDirectory "$SMPROGRAMS\scythe"
|
||||
CreateShortcut "$SMPROGRAMS\scythe\scythe.lnk" "$INSTDIR\scythe-ui.exe" "--menu"
|
||||
CreateShortcut "$DESKTOP\scythe.lnk" "$INSTDIR\scythe-ui.exe" "--menu"
|
||||
CreateShortcut "$SMPROGRAMS\scythe\scythe.lnk" "$INSTDIR\scythe.exe" "--menu"
|
||||
CreateShortcut "$DESKTOP\scythe.lnk" "$INSTDIR\scythe.exe" "--menu"
|
||||
|
||||
; Register uninstaller with Windows Control Panel / Settings
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayName" "Scythe Screen Recorder"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayIcon" "$INSTDIR\scythe-ui.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "DisplayIcon" "$INSTDIR\scythe.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\scythe" "Publisher" "oiupoyt"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
Delete "$DESKTOP\scythe.lnk"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ RequestExecutionLevel user
|
|||
|
||||
Section
|
||||
; Terminate any running instances so locked executables and DLLs can be updated cleanly
|
||||
nsExec::Exec 'taskkill /F /IM scythe.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM scythe-daemon.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-ui.exe /T'
|
||||
nsExec::Exec 'taskkill /F /IM vrec-daemon.exe /T'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
nsExec::Exec 'powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command "Get-Process -Name scythe, scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
|
||||
Sleep 1500
|
||||
|
||||
SetOverwrite try
|
||||
|
|
@ -21,11 +22,11 @@ Section
|
|||
SetOutPath "$LOCALAPPDATA\scythe"
|
||||
File /r "..\..\dist\bundle\*.*"
|
||||
|
||||
; Execute scythe-ui with any forwarded command-line arguments, or default to overlay menu
|
||||
; Execute scythe with any forwarded command-line arguments, or default to overlay menu
|
||||
${GetParameters} $R0
|
||||
${If} $R0 == ""
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe-ui.exe" --menu'
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe.exe" --menu'
|
||||
${Else}
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe-ui.exe" $R0'
|
||||
Exec '"$LOCALAPPDATA\scythe\scythe.exe" $R0'
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
|
|
|||
|
|
@ -727,6 +727,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_audio_probe() {
|
||||
// Requires a live PulseAudio/PipeWire or CoreAudio/WASAPI session. CI runners
|
||||
// have no audio server, so this is opt-in rather than a hard assertion.
|
||||
if std::env::var("SCYTHE_HW_TESTS").is_err() {
|
||||
eprintln!("skipping test_audio_probe: set SCYTHE_HW_TESTS=1 to run hardware audio tests");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Test System audio
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let sys_cap = AudioCapture::new_with_device_and_mode(tx, None, "system");
|
||||
|
|
|
|||
158
src/config.rs
158
src/config.rs
|
|
@ -10,8 +10,6 @@ fn default_fps() -> u32 { 60 }
|
|||
fn default_video_codec() -> String { "h264".to_string() }
|
||||
fn default_audio_device() -> String { "default".to_string() }
|
||||
fn default_audio_mode() -> String { "system".to_string() }
|
||||
fn default_lang() -> String { "en".to_string() }
|
||||
fn default_theme() -> String { "dark".to_string() }
|
||||
fn default_save_hotkey() -> String { "Ctrl+Shift+R".to_string() }
|
||||
fn default_menu_hotkey() -> String { "Alt+Z".to_string() }
|
||||
fn default_record_hotkey() -> String { "Ctrl+Shift+F9".to_string() }
|
||||
|
|
@ -24,14 +22,9 @@ fn default_stream_service() -> String { "twitch".to_string() }
|
|||
fn default_stream_url() -> String { "rtmp://live.twitch.tv/app/".to_string() }
|
||||
fn default_screenshot_hotkey() -> String { "Ctrl+Shift+S".to_string() }
|
||||
fn default_screenshot_format() -> String { "png".to_string() }
|
||||
fn default_capture_source() -> String { "focused".to_string() }
|
||||
fn default_video_quality_mode() -> String { "cbr".to_string() }
|
||||
fn default_color_range() -> String { "limited".to_string() }
|
||||
fn default_audio_codec() -> String { "opus".to_string() }
|
||||
fn default_replay_storage() -> String { "ram".to_string() }
|
||||
fn default_container_format() -> String { "mp4".to_string() }
|
||||
fn default_res_w() -> u32 { 1920 }
|
||||
fn default_res_h() -> u32 { 1080 }
|
||||
fn default_screenshot_quality() -> u32 { 90 }
|
||||
fn default_screenshot_preset() -> String { "balanced".to_string() }
|
||||
fn default_pause_hotkey() -> String { "Ctrl+Shift+F8".to_string() }
|
||||
|
|
@ -50,8 +43,6 @@ pub struct ScytheConfig {
|
|||
#[serde(default = "default_replay_bitrate")]
|
||||
pub replay_bitrate_kbps: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub record_enabled: bool,
|
||||
#[serde(default = "default_record_bitrate")]
|
||||
pub record_bitrate_kbps: u32,
|
||||
#[serde(default = "default_fps")]
|
||||
|
|
@ -77,10 +68,6 @@ pub struct ScytheConfig {
|
|||
pub autostart_replay: bool,
|
||||
#[serde(default)]
|
||||
pub autostart_overlay: bool,
|
||||
#[serde(default = "default_lang")]
|
||||
pub language: String,
|
||||
#[serde(default = "default_theme")]
|
||||
pub ui_color_theme: String,
|
||||
#[serde(default = "default_save_hotkey")]
|
||||
pub save_hotkey: String,
|
||||
#[serde(default = "default_menu_hotkey")]
|
||||
|
|
@ -109,40 +96,20 @@ pub struct ScytheConfig {
|
|||
#[serde(default)]
|
||||
pub screenshot_cursor: bool,
|
||||
|
||||
#[serde(default = "default_capture_source")]
|
||||
pub capture_source: String,
|
||||
#[serde(default = "default_video_quality_mode")]
|
||||
pub video_quality_mode: String,
|
||||
#[serde(default = "default_color_range")]
|
||||
pub color_range: String,
|
||||
#[serde(default = "default_audio_codec")]
|
||||
pub audio_codec: String,
|
||||
#[serde(default = "default_replay_storage")]
|
||||
pub replay_storage: String,
|
||||
#[serde(default)]
|
||||
pub record_low_power: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_replay_notifications: bool,
|
||||
|
||||
#[serde(default = "default_container_format")]
|
||||
pub container_format: String,
|
||||
#[serde(default)]
|
||||
pub change_resolution: bool,
|
||||
#[serde(default = "default_res_w")]
|
||||
pub resolution_w: u32,
|
||||
#[serde(default = "default_res_h")]
|
||||
pub resolution_h: u32,
|
||||
#[serde(default)]
|
||||
pub save_folder_by_game: bool,
|
||||
#[serde(default)]
|
||||
pub name_by_game: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub restart_replay_on_save: bool,
|
||||
#[serde(default)]
|
||||
pub show_scroll_lock_led: bool,
|
||||
#[serde(default)]
|
||||
pub autostart_on_game: bool,
|
||||
#[serde(default)]
|
||||
pub autostart_power_only: bool,
|
||||
|
||||
#[serde(default = "default_screenshot_quality")]
|
||||
|
|
@ -171,7 +138,6 @@ impl Default for ScytheConfig {
|
|||
replay_duration_sec: 60,
|
||||
replay_bitrate_kbps: 18000,
|
||||
|
||||
record_enabled: false,
|
||||
record_bitrate_kbps: 20000,
|
||||
fps: 60,
|
||||
video_codec: "h264".to_string(),
|
||||
|
|
@ -185,8 +151,6 @@ impl Default for ScytheConfig {
|
|||
autostart: false,
|
||||
autostart_replay: false,
|
||||
autostart_overlay: false,
|
||||
language: "en".to_string(),
|
||||
ui_color_theme: "dark".to_string(),
|
||||
save_hotkey: "Ctrl+Shift+R".to_string(),
|
||||
menu_hotkey: "Alt+Z".to_string(),
|
||||
record_hotkey: "Ctrl+Shift+F9".to_string(),
|
||||
|
|
@ -202,23 +166,13 @@ impl Default for ScytheConfig {
|
|||
screenshot_format: default_screenshot_format(),
|
||||
screenshot_cursor: false,
|
||||
|
||||
capture_source: default_capture_source(),
|
||||
video_quality_mode: default_video_quality_mode(),
|
||||
color_range: default_color_range(),
|
||||
audio_codec: default_audio_codec(),
|
||||
replay_storage: default_replay_storage(),
|
||||
record_low_power: false,
|
||||
show_replay_notifications: true,
|
||||
|
||||
container_format: default_container_format(),
|
||||
change_resolution: false,
|
||||
resolution_w: default_res_w(),
|
||||
resolution_h: default_res_h(),
|
||||
save_folder_by_game: false,
|
||||
name_by_game: false,
|
||||
restart_replay_on_save: true,
|
||||
show_scroll_lock_led: false,
|
||||
autostart_on_game: false,
|
||||
autostart_power_only: false,
|
||||
|
||||
screenshot_quality: default_screenshot_quality(),
|
||||
|
|
@ -365,7 +319,7 @@ Type=Application\n\
|
|||
Name=Scythe Replay Engine\n\
|
||||
Comment=Scythe Background Screen Recorder & Instant Replay Daemon\n\
|
||||
Icon=media-record\n\
|
||||
Exec=scythe-daemon\n\
|
||||
Exec=scythe daemon\n\
|
||||
Terminal=false\n\
|
||||
Hidden=false\n\
|
||||
X-GNOME-Autostart-enabled=true\n";
|
||||
|
|
@ -382,7 +336,7 @@ Type=Application\n\
|
|||
Name=Scythe Overlay\n\
|
||||
Comment=Scythe Screen Recorder HUD Overlay\n\
|
||||
Icon=media-record\n\
|
||||
Exec=scythe-ui --menu\n\
|
||||
Exec=scythe --menu\n\
|
||||
Terminal=false\n\
|
||||
Hidden=false\n\
|
||||
X-GNOME-Autostart-enabled=true\n";
|
||||
|
|
@ -397,8 +351,8 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
&& let Ok(mut text) = fs::read_to_string(&hypr_execs_lua) {
|
||||
let mut modified = false;
|
||||
|
||||
let daemon_cmd_pattern = "scythe-daemon";
|
||||
let daemon_cmd_line = " hl.exec_cmd(\"scythe-daemon\")\n";
|
||||
let daemon_cmd_pattern = "scythe daemon";
|
||||
let daemon_cmd_line = " hl.exec_cmd(\"scythe daemon\")\n";
|
||||
if self.autostart_replay {
|
||||
if !text.contains(daemon_cmd_pattern)
|
||||
&& let Some(pos) = text.rfind("end)") {
|
||||
|
|
@ -413,8 +367,8 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
modified = true;
|
||||
}
|
||||
|
||||
let overlay_cmd_pattern = "scythe-ui --menu";
|
||||
let overlay_cmd_line = " hl.exec_cmd(\"scythe-ui --menu\")\n";
|
||||
let overlay_cmd_pattern = "scythe --menu";
|
||||
let overlay_cmd_line = " hl.exec_cmd(\"scythe --menu\")\n";
|
||||
if self.autostart_overlay {
|
||||
if !text.contains(overlay_cmd_pattern)
|
||||
&& let Some(pos) = text.rfind("end)") {
|
||||
|
|
@ -436,6 +390,49 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
}
|
||||
}
|
||||
|
||||
/// Enable or disable session autostart for the daemon and the overlay.
|
||||
///
|
||||
/// Linux only: systemd user units and XDG autostart entries. Windows has no
|
||||
/// equivalent implementation yet, so the call is a no-op there.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
|
||||
pub fn apply_autostart_system(enable_replay: bool, enable_overlay: bool) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// 1. systemd user service enable / disable
|
||||
if enable_replay {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "enable", "scythe-daemon.service"])
|
||||
.status();
|
||||
} else {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "disable", "scythe-daemon.service"])
|
||||
.status();
|
||||
}
|
||||
|
||||
// 2. Standard XDG autostart desktop entry for session login
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
let autostart_dir = config_dir.join("autostart");
|
||||
let _ = std::fs::create_dir_all(&autostart_dir);
|
||||
let daemon_desktop = autostart_dir.join("scythe-daemon.desktop");
|
||||
let overlay_desktop = autostart_dir.join("scythe-overlay.desktop");
|
||||
|
||||
if enable_replay {
|
||||
let content = "[Desktop Entry]\nType=Application\nName=Scythe Replay Daemon\nExec=scythe daemon\nTerminal=false\nCategories=AudioVideo;Recorder;\nX-GNOME-Autostart-enabled=true\n";
|
||||
let _ = std::fs::write(&daemon_desktop, content);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&daemon_desktop);
|
||||
}
|
||||
|
||||
if enable_overlay {
|
||||
let content = "[Desktop Entry]\nType=Application\nName=Scythe Overlay\nExec=scythe --menu\nTerminal=false\nCategories=AudioVideo;Recorder;\nX-GNOME-Autostart-enabled=true\n";
|
||||
let _ = std::fs::write(&overlay_desktop, content);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&overlay_desktop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify_daemon_reload() {
|
||||
std::thread::spawn(|| {
|
||||
let _ = crate::ipc::send_command(crate::ipc::Command::ReloadConfig);
|
||||
|
|
@ -443,6 +440,53 @@ X-GNOME-Autostart-enabled=true\n";
|
|||
}
|
||||
|
||||
/// Formats video filenames with informative local timestamps, e.g. "Replay-18-30-00_05-09-2026.mp4"
|
||||
pub fn is_ac_power_connected() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") {
|
||||
let mut has_power_status = false;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Check AC online status
|
||||
let online_path = path.join("online");
|
||||
if let Ok(content) = std::fs::read_to_string(&online_path) {
|
||||
has_power_status = true;
|
||||
if content.trim() == "1" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check battery status
|
||||
let status_path = path.join("status");
|
||||
if let Ok(content) = std::fs::read_to_string(&status_path) {
|
||||
let s = content.trim().to_lowercase();
|
||||
if s == "charging" || s == "full" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_power_status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
unsafe {
|
||||
use windows::Win32::System::Power::{GetSystemPowerStatus, SYSTEM_POWER_STATUS};
|
||||
let mut status = SYSTEM_POWER_STATUS::default();
|
||||
if GetSystemPowerStatus(&mut status).is_ok() {
|
||||
return status.ACLineStatus == 1;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
true
|
||||
}
|
||||
|
||||
pub fn format_video_filename(prefix: &str, ext: &str) -> String {
|
||||
unsafe {
|
||||
let t = libc::time(std::ptr::null_mut());
|
||||
|
|
@ -492,21 +536,11 @@ mod tests {
|
|||
assert_eq!(parsed.stream_url, "rtmp://live.twitch.tv/app/");
|
||||
assert_eq!(parsed.stream_key, "");
|
||||
|
||||
assert_eq!(parsed.container_format, "mp4");
|
||||
assert!(!parsed.change_resolution);
|
||||
assert_eq!(parsed.resolution_w, 1920);
|
||||
assert_eq!(parsed.resolution_h, 1080);
|
||||
assert!(!parsed.save_folder_by_game);
|
||||
assert!(!parsed.name_by_game);
|
||||
assert!(parsed.restart_replay_on_save);
|
||||
assert!(!parsed.show_scroll_lock_led);
|
||||
assert!(!parsed.autostart_on_game);
|
||||
assert!(!parsed.autostart_power_only);
|
||||
assert_eq!(parsed.capture_source, "focused");
|
||||
assert_eq!(parsed.video_quality_mode, "cbr");
|
||||
assert_eq!(parsed.color_range, "limited");
|
||||
assert_eq!(parsed.audio_codec, "opus");
|
||||
assert_eq!(parsed.replay_storage, "ram");
|
||||
assert!(!parsed.record_low_power);
|
||||
assert!(parsed.show_replay_notifications);
|
||||
assert_eq!(parsed.screenshot_hotkey, "Ctrl+Shift+S");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
#![allow(clippy::chunks_exact_to_as_chunks)]
|
||||
|
||||
use scythe::capture::{Frame, FrameSource};
|
||||
use scythe::encoder::VideoEncoder;
|
||||
use scythe::ring::Packet;
|
||||
use scythe::muxer::Muxer;
|
||||
use scythe::ipc::{Command, DaemonStatus};
|
||||
use crate::capture::{Frame, FrameSource};
|
||||
use crate::encoder::VideoEncoder;
|
||||
use crate::ring::Packet;
|
||||
use crate::muxer::Muxer;
|
||||
use crate::ipc::{Command, DaemonStatus};
|
||||
use ringbuf::HeapRb;
|
||||
use ringbuf::traits::{RingBuffer, Consumer, Observer};
|
||||
use crossbeam_channel::bounded;
|
||||
|
|
@ -15,6 +13,67 @@ use std::io::{Read, Write};
|
|||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Returns true when the overlay process recorded in the pid file is still alive.
|
||||
fn overlay_alive() -> bool {
|
||||
let pid_path = crate::ipc::get_overlay_pid_path();
|
||||
let Ok(content) = std::fs::read_to_string(&pid_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(pid) = content.trim().parse::<i32>() else {
|
||||
return false;
|
||||
};
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
unsafe {
|
||||
let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid as u32) else {
|
||||
return false;
|
||||
};
|
||||
let mut code = 0u32;
|
||||
let alive = GetExitCodeProcess(handle, &mut code).is_ok() && code == STILL_ACTIVE.0 as u32;
|
||||
let _ = CloseHandle(handle);
|
||||
alive
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the HUD overlay without toggling an already-visible one closed.
|
||||
fn spawn_overlay_once() {
|
||||
if overlay_alive() {
|
||||
return;
|
||||
}
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "scythe".to_string());
|
||||
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.arg("--menu")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = cmd.spawn();
|
||||
}
|
||||
|
||||
fn mask_stream_url(url: &str) -> String {
|
||||
if let Some((base, key)) = url.rsplit_once('/')
|
||||
&& key.len() > 4 {
|
||||
|
|
@ -79,12 +138,18 @@ fn spawn_stream_worker(
|
|||
}
|
||||
|
||||
fn ensure_wayland_env() {
|
||||
scythe::overlay::ensure_wayland_env();
|
||||
crate::overlay::ensure_wayland_env();
|
||||
}
|
||||
|
||||
pub fn run_daemon() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run_daemon_async())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
#[allow(unused_assignments)]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn run_daemon_async() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
use windows::Win32::System::Console::FreeConsole;
|
||||
|
|
@ -123,11 +188,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
let initial_config = scythe::config::ScytheConfig::load();
|
||||
let mut initial_config = crate::config::ScytheConfig::load();
|
||||
if initial_config.autostart_power_only
|
||||
&& !crate::config::ScytheConfig::is_ac_power_connected() {
|
||||
eprintln!("Power-only replay armed but no AC supply detected; replay buffer paused until power returns.");
|
||||
initial_config.replay_enabled = false;
|
||||
}
|
||||
|
||||
// Automatically register dynamic keybindings on Hyprland if active (no config edits needed)
|
||||
scythe::hyprland_binds::register_hyprland_binds(&initial_config);
|
||||
scythe::hyprland_binds::spawn_hyprland_reload_watcher();
|
||||
crate::hyprland_binds::register_hyprland_binds(&initial_config);
|
||||
crate::hyprland_binds::spawn_hyprland_reload_watcher();
|
||||
|
||||
let (frame_tx, frame_rx) = bounded::<Frame>(2);
|
||||
let (cmd_tx, cmd_rx) = bounded::<Command>(32);
|
||||
|
|
@ -140,11 +210,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
let stream_start_state = Arc::new(AtomicU64::new(0));
|
||||
let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled));
|
||||
let audio_muted_state = Arc::new(AtomicBool::new(false));
|
||||
let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new());
|
||||
let audio_levels = Arc::new(crate::capture::audio::AudioLevels::new());
|
||||
|
||||
#[cfg(unix)]
|
||||
let listener = {
|
||||
let socket_path = scythe::ipc::get_socket_path();
|
||||
let socket_path = crate::ipc::get_socket_path();
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let l = std::os::unix::net::UnixListener::bind(&socket_path)?;
|
||||
println!("Daemon listening on IPC socket: {}", socket_path);
|
||||
|
|
@ -162,7 +232,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
if scythe::ipc::query_status_port(port).is_ok() {
|
||||
if crate::ipc::query_status_port(port).is_ok() {
|
||||
println!("Another instance of scythe-daemon is already running on port {}. Exiting cleanly.", port);
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -172,7 +242,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
match bound {
|
||||
Some((l, p)) => {
|
||||
println!("Daemon listening on TCP IPC: 127.0.0.1:{}", p);
|
||||
let _ = std::fs::write(scythe::ipc::get_ipc_port_path(), p.to_string());
|
||||
let _ = std::fs::write(crate::ipc::get_ipc_port_path(), p.to_string());
|
||||
let _ = std::fs::write(std::env::temp_dir().join("vrec-ipc.port"), p.to_string());
|
||||
l
|
||||
}
|
||||
|
|
@ -230,7 +300,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
} else {
|
||||
0
|
||||
};
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
let cfg = crate::config::ScytheConfig::load();
|
||||
let status = DaemonStatus {
|
||||
is_recording: rec,
|
||||
recording_duration_sec: duration,
|
||||
|
|
@ -252,30 +322,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
},
|
||||
Command::ToggleCursor => {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
let mut cfg = crate::config::ScytheConfig::load();
|
||||
cfg.show_cursor = !cfg.show_cursor;
|
||||
let _ = cfg.save();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_default();
|
||||
if session_type.to_lowercase() == "wayland" {
|
||||
println!("Cursor display toggled to: {}. Restarting Wayland session...", cfg.show_cursor);
|
||||
let _ = cmd_tx_ipc.send(Command::StopRecording);
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let _ = std::process::Command::new(exe)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
println!("Cursor display toggled to: {}", cfg.show_cursor);
|
||||
},
|
||||
Command::CycleAudioMode => {
|
||||
let mut cfg = scythe::config::ScytheConfig::load();
|
||||
let mut cfg = crate::config::ScytheConfig::load();
|
||||
cfg.audio_mode = match cfg.audio_mode.as_str() {
|
||||
"system" => "mic",
|
||||
"mic" => "both",
|
||||
|
|
@ -286,9 +339,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
println!("Audio mode cycled to: {}", cfg.audio_mode);
|
||||
let _ = cmd_tx_ipc.try_send(Command::ReloadConfig);
|
||||
},
|
||||
Command::ShowOverlay => {
|
||||
println!("ShowOverlay requested: opening HUD overlay.");
|
||||
std::thread::spawn(spawn_overlay_once);
|
||||
},
|
||||
Command::StopDaemon => {
|
||||
println!("StopDaemon requested: Finalizing active recordings...");
|
||||
scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load());
|
||||
crate::hyprland_binds::unregister_hyprland_binds(&crate::config::ScytheConfig::load());
|
||||
let _ = cmd_tx_ipc.send(Command::StopRecording);
|
||||
std::thread::sleep(std::time::Duration::from_millis(350));
|
||||
std::process::exit(0);
|
||||
|
|
@ -314,7 +371,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
println!("\nReceived shutdown signal. Finalizing recordings...");
|
||||
let _ = cmd_tx_sig.send(Command::StopRecording);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load());
|
||||
crate::hyprland_binds::unregister_hyprland_binds(&crate::config::ScytheConfig::load());
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
|
|
@ -322,7 +379,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string());
|
||||
println!("Detected session type: {}", session_type);
|
||||
|
||||
let audio_capture_initial = scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
let audio_capture_initial = crate::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
audio_tx.clone(),
|
||||
Some(&initial_config.audio_device),
|
||||
&initial_config.audio_mode,
|
||||
|
|
@ -335,12 +392,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
if std::env::args().any(|a| a == "--mock") {
|
||||
println!("Initializing MOCK capture...");
|
||||
source_opt = Some(Box::new(scythe::capture::mock::MockCapture::new()));
|
||||
source_opt = Some(Box::new(crate::capture::mock::MockCapture::new()));
|
||||
} else if cfg!(target_os = "windows") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!("Initializing Windows DXGI capture...");
|
||||
if let Ok(c) = scythe::capture::windows::WindowsCapture::new() {
|
||||
if let Ok(c) = crate::capture::windows::WindowsCapture::new() {
|
||||
source_opt = Some(Box::new(c));
|
||||
}
|
||||
}
|
||||
|
|
@ -354,7 +411,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
.args(["--user", "start", "xdg-desktop-portal-hyprland"])
|
||||
.status();
|
||||
|
||||
match scythe::capture::wayland::WaylandCapture::new_with_cursor(initial_config.show_cursor).await {
|
||||
match crate::capture::wayland::WaylandCapture::new_with_cursor(initial_config.show_cursor).await {
|
||||
Ok(cap) => {
|
||||
source_opt = Some(Box::new(cap));
|
||||
break;
|
||||
|
|
@ -375,7 +432,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!("Initializing X11 capture...");
|
||||
if let Ok(c) = scythe::capture::x11::X11Capture::new() {
|
||||
if let Ok(c) = crate::capture::x11::X11Capture::new() {
|
||||
source_opt = Some(Box::new(c));
|
||||
}
|
||||
}
|
||||
|
|
@ -387,7 +444,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
None => {
|
||||
if is_mock_cli {
|
||||
eprintln!("Warning: Mock capture mode active via --mock flag.");
|
||||
Box::new(scythe::capture::mock::MockCapture::new())
|
||||
Box::new(crate::capture::mock::MockCapture::new())
|
||||
} else {
|
||||
eprintln!("Error: Hardware capture failed to initialize on {} session. Exiting for restart.", session_type);
|
||||
std::process::exit(1);
|
||||
|
|
@ -454,9 +511,45 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
#[allow(unused_variables, unused_assignments)]
|
||||
let mut audio_capture = audio_capture_initial.map(|(c, _, _)| c);
|
||||
|
||||
fn effective_replay_active(cfg: &crate::config::ScytheConfig) -> bool {
|
||||
if !cfg.replay_enabled {
|
||||
return false;
|
||||
}
|
||||
if cfg.autostart_power_only && !crate::config::ScytheConfig::is_ac_power_connected() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
let cmd_tx_power = cmd_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut last_ac = crate::config::ScytheConfig::is_ac_power_connected();
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
let now_ac = crate::config::ScytheConfig::is_ac_power_connected();
|
||||
if now_ac != last_ac {
|
||||
last_ac = now_ac;
|
||||
let _ = cmd_tx_power.send(Command::PowerStateChanged(now_ac));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fn spawn_power_toast(title: &str, subtitle: &str, icon: &str) {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "scythe".to_string());
|
||||
let _ = std::process::Command::new(exe)
|
||||
.args(["--toast", title, subtitle, icon])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
let recorder_handle = thread::spawn(move || {
|
||||
let mut config = scythe::config::ScytheConfig::load();
|
||||
replay_state_clone.store(config.replay_enabled, Ordering::SeqCst);
|
||||
let mut config = crate::config::ScytheConfig::load();
|
||||
let mut replay_effective = effective_replay_active(&config);
|
||||
replay_state_clone.store(replay_effective, Ordering::SeqCst);
|
||||
|
||||
let active_bitrate = if config.replay_enabled {
|
||||
config.replay_bitrate_kbps
|
||||
|
|
@ -477,9 +570,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
println!("Background recorder engine active and ready ({} fps, replay: {}).", config.fps, config.replay_enabled);
|
||||
|
||||
let mut audio_encoder = if let Some((sr, ch)) = audio_info {
|
||||
scythe::encoder::AudioEncoder::new_with_codec(sr as i32, ch as i32, &config.audio_codec).ok()
|
||||
crate::encoder::AudioEncoder::new_with_codec(sr as i32, ch as i32, &config.audio_codec).ok()
|
||||
} else {
|
||||
scythe::encoder::AudioEncoder::new_with_codec(48000, 2, &config.audio_codec).ok()
|
||||
crate::encoder::AudioEncoder::new_with_codec(48000, 2, &config.audio_codec).ok()
|
||||
};
|
||||
let audio_codec_ctx_ptr = audio_encoder.as_ref().map(|e| e.codec_ctx() as usize);
|
||||
|
||||
|
|
@ -511,8 +604,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
thread::spawn(move || {
|
||||
while let Ok(drain) = mux_rx.recv() {
|
||||
let filename = scythe::config::ScytheConfig::format_video_filename("Replay", "mp4");
|
||||
let full_path = scythe::config::ScytheConfig::load().resolve_save_path(&filename);
|
||||
let filename = crate::config::ScytheConfig::format_video_filename("Replay", "mp4");
|
||||
let full_path = crate::config::ScytheConfig::load().resolve_save_path(&filename);
|
||||
println!("Saving replay to {}...", full_path);
|
||||
|
||||
// Find the first video IDR keyframe (ignore audio packets which always have keyframe flag set)
|
||||
|
|
@ -639,7 +732,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
rec_base_video_pts = pkt.pts();
|
||||
rec_base_audio_pts = -1;
|
||||
let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext;
|
||||
let filename = scythe::config::ScytheConfig::format_video_filename("Recording", "mp4");
|
||||
let filename = crate::config::ScytheConfig::format_video_filename("Recording", "mp4");
|
||||
let full_path = config.resolve_save_path(&filename);
|
||||
let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext);
|
||||
match unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx) } {
|
||||
|
|
@ -678,7 +771,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
}
|
||||
|
||||
if config.replay_enabled {
|
||||
if replay_effective {
|
||||
ring.push_overwrite(pkt);
|
||||
}
|
||||
}
|
||||
|
|
@ -696,7 +789,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
if let Ok(cmd) = cmd_res {
|
||||
match cmd {
|
||||
Command::ReloadConfig => {
|
||||
let new_config = scythe::config::ScytheConfig::load();
|
||||
let new_config = crate::config::ScytheConfig::load();
|
||||
let video_changed = new_config.show_cursor != config.show_cursor
|
||||
|| new_config.fps != config.fps
|
||||
|| new_config.video_codec != config.video_codec
|
||||
|
|
@ -740,9 +833,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
std::process::exit(0);
|
||||
}
|
||||
config = new_config;
|
||||
replay_state_clone.store(config.replay_enabled, Ordering::SeqCst);
|
||||
replay_effective = effective_replay_active(&config);
|
||||
replay_state_clone.store(replay_effective, Ordering::SeqCst);
|
||||
if !replay_effective && config.replay_enabled {
|
||||
while ring.try_pop().is_some() {}
|
||||
eprintln!("Replay paused: power-only armed and running on battery.");
|
||||
}
|
||||
audio_muted_clone.store(config.audio_mode == "muted", Ordering::SeqCst);
|
||||
println!("Daemon config reloaded! Audio mode: {}", config.audio_mode);
|
||||
println!("Daemon config reloaded! Audio mode: {} replay_effective: {}", config.audio_mode, replay_effective);
|
||||
let new_capacity = if config.replay_enabled {
|
||||
(config.replay_duration_sec as usize) * (config.fps as usize + 50) + 60
|
||||
} else {
|
||||
|
|
@ -754,8 +852,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
#[cfg(target_os = "linux")]
|
||||
unsafe { libc::malloc_trim(0); }
|
||||
}
|
||||
scythe::hyprland_binds::register_hyprland_binds(&config);
|
||||
audio_capture = match scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
crate::hyprland_binds::register_hyprland_binds(&config);
|
||||
audio_capture = match crate::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels(
|
||||
audio_tx_clone.clone(),
|
||||
Some(&config.audio_device),
|
||||
&config.audio_mode,
|
||||
|
|
@ -774,8 +872,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
};
|
||||
ticker = crossbeam_channel::tick(std::time::Duration::from_nanos(1_000_000_000 / config.fps.max(1) as u64));
|
||||
},
|
||||
Command::PowerStateChanged(on_ac) => {
|
||||
let was_effective = replay_effective;
|
||||
replay_effective = effective_replay_active(&config);
|
||||
replay_state_clone.store(replay_effective, Ordering::SeqCst);
|
||||
if !replay_effective && (was_effective || !on_ac) {
|
||||
while ring.try_pop().is_some() {}
|
||||
eprintln!("Power lost: replay buffer paused and drained.");
|
||||
if config.autostart_power_only && config.show_replay_notifications {
|
||||
spawn_power_toast("POWER", "power lost, replay paused", "error");
|
||||
}
|
||||
} else if replay_effective && !was_effective {
|
||||
encoder.force_keyframe();
|
||||
has_new_frame = true;
|
||||
println!("Power restored: replay buffer resumed.");
|
||||
if config.show_replay_notifications {
|
||||
spawn_power_toast("POWER", "power restored, replay resumed", "replay");
|
||||
}
|
||||
}
|
||||
},
|
||||
Command::SaveReplay => {
|
||||
if config.replay_enabled {
|
||||
if replay_effective {
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(last_replay_save) > std::time::Duration::from_millis(800) {
|
||||
last_replay_save = now;
|
||||
|
|
@ -856,7 +973,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
println!("Audio mute toggled: {}", !cur);
|
||||
},
|
||||
Command::StartStreaming => {
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
let cfg = crate::config::ScytheConfig::load();
|
||||
let key = cfg.stream_key.trim();
|
||||
if key.is_empty() {
|
||||
eprintln!("Cannot start livestream: stream key is empty in config.");
|
||||
|
|
@ -903,7 +1020,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
stream_start_clone.store(0, Ordering::SeqCst);
|
||||
println!("ToggleStreaming: Livestream stopped.");
|
||||
} else {
|
||||
let cfg = scythe::config::ScytheConfig::load();
|
||||
let cfg = crate::config::ScytheConfig::load();
|
||||
let key = cfg.stream_key.trim();
|
||||
if key.is_empty() {
|
||||
eprintln!("Cannot start livestream: stream key is empty in config.");
|
||||
|
|
@ -1000,7 +1117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
let _ = tx.try_send(rebased);
|
||||
}
|
||||
}
|
||||
if config.replay_enabled {
|
||||
if replay_effective {
|
||||
ring.push_overwrite(pkt);
|
||||
}
|
||||
}
|
||||
|
|
@ -1018,7 +1135,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
|
||||
let is_streaming = stream_sender.is_some();
|
||||
if !normal_recording && !config.replay_enabled && !is_streaming {
|
||||
if !normal_recording && !replay_effective && !is_streaming {
|
||||
has_new_frame = false;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1162,6 +1279,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
let _ = recorder_handle.join();
|
||||
#[cfg(windows)]
|
||||
let _ = std::fs::remove_file(scythe::ipc::get_ipc_port_path());
|
||||
let _ = std::fs::remove_file(crate::ipc::get_ipc_port_path());
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ pub enum Command {
|
|||
CycleAudioMode,
|
||||
ToggleCursor,
|
||||
ReloadConfig,
|
||||
PowerStateChanged(bool),
|
||||
StopDaemon,
|
||||
ShowOverlay,
|
||||
GetStatus,
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ pub mod config;
|
|||
pub mod overlay_egui;
|
||||
pub mod hyprland_binds;
|
||||
pub mod updater;
|
||||
pub mod daemon;
|
||||
|
|
|
|||
|
|
@ -12,44 +12,14 @@ fn ensure_wayland_env() {
|
|||
|
||||
fn get_daemon_cmd() -> std::process::Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = if let Ok(mut path) = std::env::current_exe() {
|
||||
path.pop();
|
||||
#[cfg(target_os = "windows")]
|
||||
let primary = path.join("scythe-daemon.exe");
|
||||
#[cfg(target_os = "windows")]
|
||||
let fallback = path.join("vrec-daemon.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let primary = path.join("scythe-daemon");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let fallback = path.join("vrec-daemon");
|
||||
|
||||
if primary.exists() {
|
||||
let mut c = std::process::Command::new(primary);
|
||||
c.current_dir(&path);
|
||||
c
|
||||
} else if fallback.exists() {
|
||||
let mut c = std::process::Command::new(fallback);
|
||||
c.current_dir(&path);
|
||||
let mut cmd = if let Ok(self_exe) = std::env::current_exe() {
|
||||
let mut c = std::process::Command::new(self_exe);
|
||||
c.arg("daemon");
|
||||
c
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::process::Command::new("scythe-daemon")
|
||||
}
|
||||
let mut c = std::process::Command::new("scythe");
|
||||
c.arg("daemon");
|
||||
c
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -275,10 +245,12 @@ fn handle_toggle_recording() -> Result<(), Box<dyn std::error::Error + Send + Sy
|
|||
|
||||
fn handle_toggle_cursor() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
ensure_daemon_running();
|
||||
let current_cursor = query_status().ok().map(|s| s.show_cursor).unwrap_or(true);
|
||||
let next_cursor = !current_cursor;
|
||||
match send_command(Command::ToggleCursor) {
|
||||
Ok(()) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let next_cursor = query_status().ok().map(|s| s.show_cursor).unwrap_or_else(|| {
|
||||
scythe::config::ScytheConfig::load().show_cursor
|
||||
});
|
||||
if next_cursor {
|
||||
show_shadowplay_toast("MOUSE CURSOR", "Visible in recording", ToastIcon::Cursor);
|
||||
} else {
|
||||
|
|
@ -412,9 +384,45 @@ fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sy
|
|||
}
|
||||
}
|
||||
|
||||
/// Name the binary was invoked as.
|
||||
///
|
||||
/// `current_exe()` reads /proc/self/exe, which resolves symlinks to the real
|
||||
/// file, so the installed `scythe-daemon` / `scythe-ui` aliases would all
|
||||
/// report "scythe" and dispatch to the client. argv[0] keeps the invocation
|
||||
/// name, which is what multicall dispatch needs.
|
||||
fn invoked_stem() -> String {
|
||||
if let Some(arg0) = env::args_os().next() {
|
||||
let path = std::path::Path::new(&arg0);
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let stem = stem.to_string_lossy().to_string();
|
||||
if !stem.is_empty() {
|
||||
return stem;
|
||||
}
|
||||
}
|
||||
}
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let exe_name = invoked_stem();
|
||||
|
||||
// Multicall dispatch: scythe-daemon executable, symlink, or subcommand
|
||||
let is_daemon = exe_name == "scythe-daemon"
|
||||
|| exe_name == "vrec-daemon"
|
||||
|| (args.len() > 1 && (args[1] == "daemon" || args[1] == "--daemon" || args[1] == "--mock"));
|
||||
|
||||
if is_daemon {
|
||||
return scythe::daemon::run_daemon();
|
||||
}
|
||||
|
||||
run_client(args)
|
||||
}
|
||||
|
||||
fn run_client(args: Vec<String>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
let is_cli = args.iter().any(|a| a == "--status" || a == "--help" || a == "-h");
|
||||
|
|
@ -428,7 +436,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"--save" | "--save-replay" => {
|
||||
"--save" | "--save-replay" | "save" => {
|
||||
return handle_save_replay();
|
||||
}
|
||||
"--notify-save" => {
|
||||
|
|
@ -443,7 +451,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
show_shadowplay_toast("RECORDING", "Recording saved", ToastIcon::Save);
|
||||
return Ok(());
|
||||
}
|
||||
"--screenshot" => {
|
||||
"--screenshot" | "screenshot" => {
|
||||
return handle_screenshot();
|
||||
}
|
||||
"--mute-mic" => {
|
||||
|
|
@ -455,6 +463,17 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
"--pause" => {
|
||||
return handle_toggle_recording();
|
||||
}
|
||||
"--show-overlay" | "--open" => {
|
||||
ensure_daemon_running();
|
||||
// Daemon owns overlay lifetime; if it is unreachable, open the HUD directly.
|
||||
if send_command(Command::ShowOverlay).is_err() {
|
||||
ensure_hotkeys_running();
|
||||
if !check_and_toggle_overlay() {
|
||||
scythe::overlay_egui::run_egui_overlay();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"--toast" => {
|
||||
let title = args.get(2).map(|s| s.as_str()).unwrap_or("SCYTHE");
|
||||
let subtitle = args.get(3).map(|s| s.as_str()).unwrap_or("");
|
||||
|
|
@ -462,7 +481,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
scythe::overlay_egui::run_egui_toast(title, subtitle, ToastIcon::from_name(icon_name));
|
||||
return Ok(());
|
||||
}
|
||||
"--menu" => {
|
||||
"--menu" | "menu" => {
|
||||
ensure_daemon_running_async();
|
||||
ensure_hotkeys_running();
|
||||
if check_and_toggle_overlay() {
|
||||
|
|
@ -492,7 +511,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
"--hotkeys" | "--background" => {
|
||||
// fall through to hotkey loop
|
||||
}
|
||||
"--record" | "--toggle" => {
|
||||
"--record" | "--toggle" | "record" => {
|
||||
return handle_toggle_recording();
|
||||
}
|
||||
"--stream" | "--toggle-stream" => {
|
||||
|
|
@ -511,7 +530,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
ensure_daemon_running();
|
||||
return send_command(Command::ReloadConfig);
|
||||
}
|
||||
"--status" => {
|
||||
"--status" | "status" => {
|
||||
ensure_daemon_running();
|
||||
match query_status() {
|
||||
Ok(st) => {
|
||||
|
|
@ -531,33 +550,38 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"--quit" => {
|
||||
"--quit" | "quit" => {
|
||||
return send_command(Command::StopDaemon);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("scythe-ui - UI Overlay and Hotkey listener for scythe");
|
||||
println!("scythe {} - GPU screen recorder, instant replay and HUD overlay", scythe::updater::CURRENT_VERSION);
|
||||
println!("Usage:");
|
||||
println!(" scythe-ui Open the interactive overlay UI menu (default)");
|
||||
println!(" scythe-ui --menu Open the interactive overlay UI menu");
|
||||
println!(" scythe-ui --gtk Open legacy GTK layer-shell overlay (Linux)");
|
||||
println!(" scythe-ui --hotkeys Run global hotkey manager in background");
|
||||
println!(" scythe-ui --status Query current daemon status");
|
||||
println!(" scythe-ui --save Save instant replay and show notification");
|
||||
println!(" scythe-ui --save-replay Alias for --save");
|
||||
println!(" scythe-ui --notify-save Show instant replay saved notification toast");
|
||||
println!(" scythe-ui --notify-start Show recording started notification toast");
|
||||
println!(" scythe-ui --notify-stop Show recording saved notification toast");
|
||||
println!(" scythe-ui --toast <t> <s> Show custom ShadowPlay notification toast");
|
||||
println!(" scythe-ui --record Toggle normal recording on/off (with notification)");
|
||||
println!(" scythe-ui --stream Toggle livestreaming on/off (with notification)");
|
||||
println!(" scythe-ui --cursor Toggle mouse cursor recording on/off (with notification)");
|
||||
println!(" scythe-ui --start Start normal recording");
|
||||
println!(" scythe-ui --stop Stop normal recording");
|
||||
println!(" scythe-ui --reload Reload daemon configuration");
|
||||
println!(" scythe-ui --quit Stop background daemon");
|
||||
println!(" scythe Open the interactive overlay UI menu (default)");
|
||||
println!(" scythe --menu Open the interactive overlay UI menu");
|
||||
println!(" scythe --gtk Deprecated alias for --menu (GTK backend retired)");
|
||||
println!(" scythe --hotkeys Run global hotkey manager in background");
|
||||
println!(" scythe --status Query current daemon status");
|
||||
println!(" scythe --save Save instant replay and show notification");
|
||||
println!(" scythe --save-replay Alias for --save");
|
||||
println!(" scythe --notify-save Show instant replay saved notification toast");
|
||||
println!(" scythe --notify-start Show recording started notification toast");
|
||||
println!(" scythe --notify-stop Show recording saved notification toast");
|
||||
println!(" scythe --toast <t> <s> Show custom ShadowPlay notification toast");
|
||||
println!(" scythe --record Toggle normal recording on/off (with notification)");
|
||||
println!(" scythe --stream Toggle livestreaming on/off (with notification)");
|
||||
println!(" scythe --cursor Toggle mouse cursor recording on/off (with notification)");
|
||||
println!(" scythe --show-overlay Open the HUD overlay via the daemon (no toggle)");
|
||||
println!(" scythe --start Start normal recording");
|
||||
println!(" scythe --stop Stop normal recording");
|
||||
println!(" scythe --reload Reload daemon configuration");
|
||||
println!(" scythe --quit Stop background daemon");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
other => {
|
||||
eprintln!("scythe: unknown argument '{}'", other);
|
||||
eprintln!("Run 'scythe --help' for usage.");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ensure_daemon_running_async();
|
||||
|
|
@ -10,9 +10,22 @@ pub fn show_shadowplay_toast(title: &str, subtitle: &str, icon: ToastIcon) {
|
|||
ToastIcon::Error => "error",
|
||||
ToastIcon::Info => "info",
|
||||
};
|
||||
let _ = std::process::Command::new("scythe-ui")
|
||||
.args(["--toast", title, subtitle, icon_str])
|
||||
let toast_argv = ["--toast", title, subtitle, icon_str];
|
||||
if let Ok(self_exe) = std::env::current_exe() {
|
||||
let _ = std::process::Command::new(self_exe)
|
||||
.args(toast_argv)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
} else {
|
||||
let _ = std::process::Command::new("scythe")
|
||||
.args(toast_argv)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_wayland_env() {
|
||||
|
|
|
|||
|
|
@ -570,26 +570,14 @@ fn pick_folder(current_dir: &str, tx: Sender<String>, is_active: Arc<AtomicBool>
|
|||
}
|
||||
|
||||
fn spawn_daemon_process() {
|
||||
let mut cmd = if let Ok(mut path) = std::env::current_exe() {
|
||||
path.pop();
|
||||
#[cfg(target_os = "windows")]
|
||||
let primary = path.join("scythe-daemon.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let primary = path.join("scythe-daemon");
|
||||
|
||||
if primary.exists() {
|
||||
std::process::Command::new(primary)
|
||||
let mut cmd = if let Ok(self_exe) = std::env::current_exe() {
|
||||
let mut c = std::process::Command::new(self_exe);
|
||||
c.arg("daemon");
|
||||
c
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ std::process::Command::new("scythe-daemon.exe") }
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{ std::process::Command::new("scythe-daemon") }
|
||||
}
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ std::process::Command::new("scythe-daemon.exe") }
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{ std::process::Command::new("scythe-daemon") }
|
||||
let mut c = std::process::Command::new("scythe");
|
||||
c.arg("daemon");
|
||||
c
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -1143,28 +1131,18 @@ pub struct ScytheOverlayApp {
|
|||
pub screenshot_dir: String,
|
||||
pub screenshot_format: String,
|
||||
pub screenshot_cursor: bool,
|
||||
pub capture_source: String,
|
||||
pub video_quality_mode: String,
|
||||
pub color_range: String,
|
||||
pub replay_storage: String,
|
||||
pub record_low_power: bool,
|
||||
pub show_replay_notifications: bool,
|
||||
pub container_format: String,
|
||||
pub change_resolution: bool,
|
||||
pub resolution_w: u32,
|
||||
pub resolution_h: u32,
|
||||
pub res_w_str: String,
|
||||
pub res_h_str: String,
|
||||
pub save_folder_by_game: bool,
|
||||
pub name_by_game: bool,
|
||||
pub restart_replay_on_save: bool,
|
||||
pub show_scroll_lock_led: bool,
|
||||
pub autostart_on_game: bool,
|
||||
pub autostart_power_only: bool,
|
||||
pub screenshot_quality: u32,
|
||||
pub screenshot_preset: String,
|
||||
pub screenshot_resolution: String,
|
||||
pub use_monospace_font: bool,
|
||||
pub mic_test_active: bool,
|
||||
pub sys_test_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -1237,24 +1215,12 @@ impl ScytheOverlayApp {
|
|||
let screenshot_dir = config.screenshot_directory.clone();
|
||||
let screenshot_format = config.screenshot_format.clone();
|
||||
let screenshot_cursor = config.screenshot_cursor;
|
||||
let capture_source = config.capture_source.clone();
|
||||
let video_quality_mode = config.video_quality_mode.clone();
|
||||
let color_range = config.color_range.clone();
|
||||
let replay_storage = config.replay_storage.clone();
|
||||
let record_low_power = config.record_low_power;
|
||||
let show_replay_notifications = config.show_replay_notifications;
|
||||
|
||||
let container_format = config.container_format.clone();
|
||||
let change_resolution = config.change_resolution;
|
||||
let resolution_w = config.resolution_w;
|
||||
let resolution_h = config.resolution_h;
|
||||
let res_w_str = resolution_w.to_string();
|
||||
let res_h_str = resolution_h.to_string();
|
||||
let save_folder_by_game = config.save_folder_by_game;
|
||||
let name_by_game = config.name_by_game;
|
||||
let restart_replay_on_save = config.restart_replay_on_save;
|
||||
let show_scroll_lock_led = config.show_scroll_lock_led;
|
||||
let autostart_on_game = config.autostart_on_game;
|
||||
let autostart_power_only = config.autostart_power_only;
|
||||
let screenshot_quality = config.screenshot_quality;
|
||||
let screenshot_preset = config.screenshot_preset.clone();
|
||||
|
|
@ -1321,28 +1287,18 @@ impl ScytheOverlayApp {
|
|||
screenshot_dir,
|
||||
screenshot_format,
|
||||
screenshot_cursor,
|
||||
capture_source,
|
||||
video_quality_mode,
|
||||
color_range,
|
||||
replay_storage,
|
||||
record_low_power,
|
||||
show_replay_notifications,
|
||||
container_format,
|
||||
change_resolution,
|
||||
resolution_w,
|
||||
resolution_h,
|
||||
res_w_str,
|
||||
res_h_str,
|
||||
save_folder_by_game,
|
||||
name_by_game,
|
||||
restart_replay_on_save,
|
||||
show_scroll_lock_led,
|
||||
autostart_on_game,
|
||||
autostart_power_only,
|
||||
screenshot_quality,
|
||||
screenshot_preset,
|
||||
screenshot_resolution,
|
||||
use_monospace_font,
|
||||
mic_test_active: false,
|
||||
sys_test_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1376,21 +1332,11 @@ impl ScytheOverlayApp {
|
|||
self.config.screenshot_directory = self.screenshot_dir.clone();
|
||||
self.config.screenshot_format = self.screenshot_format.clone();
|
||||
self.config.screenshot_cursor = self.screenshot_cursor;
|
||||
self.config.capture_source = self.capture_source.clone();
|
||||
self.config.video_quality_mode = self.video_quality_mode.clone();
|
||||
self.config.color_range = self.color_range.clone();
|
||||
self.config.replay_storage = self.replay_storage.clone();
|
||||
self.config.record_low_power = self.record_low_power;
|
||||
self.config.show_replay_notifications = self.show_replay_notifications;
|
||||
self.config.container_format = self.container_format.clone();
|
||||
self.config.change_resolution = self.change_resolution;
|
||||
self.config.resolution_w = self.resolution_w;
|
||||
self.config.resolution_h = self.resolution_h;
|
||||
self.config.save_folder_by_game = self.save_folder_by_game;
|
||||
self.config.name_by_game = self.name_by_game;
|
||||
self.config.restart_replay_on_save = self.restart_replay_on_save;
|
||||
self.config.show_scroll_lock_led = self.show_scroll_lock_led;
|
||||
self.config.autostart_on_game = self.autostart_on_game;
|
||||
self.config.autostart_power_only = self.autostart_power_only;
|
||||
self.config.screenshot_quality = self.screenshot_quality;
|
||||
self.config.screenshot_preset = self.screenshot_preset.clone();
|
||||
|
|
@ -1421,23 +1367,11 @@ impl ScytheOverlayApp {
|
|||
self.screenshot_dir = def.screenshot_directory;
|
||||
self.screenshot_format = def.screenshot_format;
|
||||
self.screenshot_cursor = def.screenshot_cursor;
|
||||
self.capture_source = def.capture_source;
|
||||
self.video_quality_mode = def.video_quality_mode;
|
||||
self.color_range = def.color_range;
|
||||
self.replay_storage = def.replay_storage;
|
||||
self.record_low_power = def.record_low_power;
|
||||
self.show_replay_notifications = def.show_replay_notifications;
|
||||
self.container_format = def.container_format;
|
||||
self.change_resolution = def.change_resolution;
|
||||
self.resolution_w = def.resolution_w;
|
||||
self.resolution_h = def.resolution_h;
|
||||
self.res_w_str = def.resolution_w.to_string();
|
||||
self.res_h_str = def.resolution_h.to_string();
|
||||
self.save_folder_by_game = def.save_folder_by_game;
|
||||
self.name_by_game = def.name_by_game;
|
||||
self.restart_replay_on_save = def.restart_replay_on_save;
|
||||
self.show_scroll_lock_led = def.show_scroll_lock_led;
|
||||
self.autostart_on_game = def.autostart_on_game;
|
||||
self.autostart_power_only = def.autostart_power_only;
|
||||
self.screenshot_quality = def.screenshot_quality;
|
||||
self.screenshot_preset = def.screenshot_preset;
|
||||
|
|
@ -2263,6 +2197,179 @@ impl ScytheOverlayApp {
|
|||
});
|
||||
}
|
||||
|
||||
fn render_audio_controls(&mut self, ui: &mut egui::Ui, accent: Color32) {
|
||||
render_section_card(ui, "Audio", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Audio capture source mode:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let modes = [("system", "System Audio"), ("mic", "Microphone"), ("both", "Both"), ("muted", "Muted")];
|
||||
for (idx, (_, m_label)) in modes.iter().enumerate() {
|
||||
if squared_button(ui, m_label, self.audio_mode_idx == idx, accent) {
|
||||
self.audio_mode_idx = idx;
|
||||
self.config.audio_mode = match idx {
|
||||
1 => "mic".to_string(),
|
||||
2 => "both".to_string(),
|
||||
3 => "muted".to_string(),
|
||||
_ => "system".to_string(),
|
||||
};
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Audio codec:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
if squared_button(ui, "Opus (Recommended)", self.config.audio_codec == "opus", accent) {
|
||||
self.config.audio_codec = "opus".to_string();
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
}
|
||||
if squared_button(ui, "AAC", self.config.audio_codec == "aac", accent) {
|
||||
self.config.audio_codec = "aac".to_string();
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Sys Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut sv = self.system_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut sv, 0..=150).suffix("%")).changed() {
|
||||
self.system_volume_pct = sv;
|
||||
self.config.system_volume = sv as f32 / 100.0;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Mic Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut mv = self.mic_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut mv, 0..=150).suffix("%")).changed() {
|
||||
self.mic_volume_pct = mv;
|
||||
self.config.mic_volume = mv as f32 / 100.0;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("SYS").font(FontId::monospace(10.0)).color(Color32::from_rgb(160, 170, 185)));
|
||||
render_vu_meter(ui, self.sys_vu, (ui.available_width() - 8.0) * 0.45, 10.0, "");
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("MIC").font(FontId::monospace(10.0)).color(Color32::from_rgb(160, 170, 185)));
|
||||
render_vu_meter(ui, self.mic_vu, ui.available_width() - 8.0, 10.0, "");
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
|
||||
ui.label(egui::RichText::new("verify inputs").font(FontId::monospace(10.0)).color(Color32::from_rgb(102, 102, 102)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if squared_button_sized(ui, if self.mic_test_active { "stop mic" } else { "test mic" }, self.mic_test_active, accent, Some(Vec2::new(110.0, 24.0))) {
|
||||
self.mic_test_active = !self.mic_test_active;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if squared_button_sized(ui, if self.sys_test_active { "stop sys" } else { "test sys" }, self.sys_test_active, accent, Some(Vec2::new(110.0, 24.0))) {
|
||||
self.sys_test_active = !self.sys_test_active;
|
||||
}
|
||||
if self.sys_test_active {
|
||||
ui.add_space(8.0);
|
||||
if squared_button_sized(ui, "chime", false, accent, Some(Vec2::new(80.0, 24.0))) {
|
||||
std::thread::spawn(|| {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let candidates = [
|
||||
"/usr/share/sounds/freedesktop/stereo/audio-channel-front-center.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/complete.oga",
|
||||
"/usr/share/sounds/freedesktop/stereo/bell.oga",
|
||||
];
|
||||
for p in candidates {
|
||||
if std::path::Path::new(p).exists() {
|
||||
let _ = std::process::Command::new("paplay").arg(p).status();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", "[console]::beep(880, 250)"])
|
||||
.status();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if self.mic_test_active {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::NONE
|
||||
.fill(Color32::from_rgb(14, 14, 14))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 34, 34)))
|
||||
.corner_radius(CornerRadius::ZERO)
|
||||
.inner_margin(Margin::same(8_i8))
|
||||
.show(ui, |ui| {
|
||||
let pct = (self.mic_vu * 100.0).round() as u32;
|
||||
let db = if self.mic_vu > 0.001 {
|
||||
(20.0 * self.mic_vu.log10()).max(-60.0)
|
||||
} else {
|
||||
-60.0
|
||||
};
|
||||
let live = self.mic_vu > 0.02;
|
||||
let dot = if live { Color32::from_rgb(74, 222, 128) } else { Color32::from_rgb(68, 68, 68) };
|
||||
let status_text = if live { "live" } else { "silence: speak to verify" };
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let (dr, _) = ui.allocate_exact_size(Vec2::new(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(dr, CornerRadius::ZERO, dot);
|
||||
ui.label(egui::RichText::new(format!("mic {:3}% / {:5.1}db", pct, db)).font(FontId::monospace(11.0)).color(Color32::WHITE));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(status_text).font(FontId::monospace(10.0)).color(Color32::from_rgb(136, 136, 136)));
|
||||
});
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
render_vu_meter(ui, self.mic_vu, ui.available_width() - 4.0, 12.0, "");
|
||||
});
|
||||
}
|
||||
|
||||
if self.sys_test_active {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::NONE
|
||||
.fill(Color32::from_rgb(14, 14, 14))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 34, 34)))
|
||||
.corner_radius(CornerRadius::ZERO)
|
||||
.inner_margin(Margin::same(8_i8))
|
||||
.show(ui, |ui| {
|
||||
let pct = (self.sys_vu * 100.0).round() as u32;
|
||||
let db = if self.sys_vu > 0.001 {
|
||||
(20.0 * self.sys_vu.log10()).max(-60.0)
|
||||
} else {
|
||||
-60.0
|
||||
};
|
||||
let live = self.sys_vu > 0.02;
|
||||
let dot = if live { Color32::from_rgb(74, 222, 128) } else { Color32::from_rgb(68, 68, 68) };
|
||||
let status_text = if live { "live" } else { "silence: play audio or chime" };
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let (dr, _) = ui.allocate_exact_size(Vec2::new(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(dr, CornerRadius::ZERO, dot);
|
||||
ui.label(egui::RichText::new(format!("sys {:3}% / {:5.1}db", pct, db)).font(FontId::monospace(11.0)).color(Color32::WHITE));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(status_text).font(FontId::monospace(10.0)).color(Color32::from_rgb(136, 136, 136)));
|
||||
});
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
render_vu_meter(ui, self.sys_vu, ui.available_width() - 4.0, 12.0, "");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_tab_replay(&mut self, ui: &mut egui::Ui, accent: Color32) {
|
||||
render_section_card(ui, "File info", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Directory to save replays:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
|
|
@ -2280,24 +2387,17 @@ impl ScytheOverlayApp {
|
|||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Container:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
if squared_button(ui, "mp4", self.container_format == "mp4", accent) {
|
||||
self.container_format = "mp4".to_string();
|
||||
}
|
||||
if squared_button(ui, "mkv", self.container_format == "mkv", accent) {
|
||||
self.container_format = "mkv".to_string();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Replay duration in seconds:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(2.0);
|
||||
numeric_input_box(ui, &mut self.replay_sec_input_str, &mut self.replay_sec, 5, 1800, 10, "sec", accent);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Output:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new("MP4 / H.264 + OPUS").font(FontId::monospace(10.5)).color(Color32::from_rgb(140, 150, 165)));
|
||||
});
|
||||
});
|
||||
|
||||
let mins = self.replay_sec / 60;
|
||||
|
|
@ -2319,26 +2419,6 @@ impl ScytheOverlayApp {
|
|||
ui.add_space(10.0);
|
||||
|
||||
render_section_card(ui, "General", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Where should temporary replay data be stored?").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if squared_button(ui, "RAM", self.replay_storage == "ram", accent) {
|
||||
self.replay_storage = "ram".to_string();
|
||||
}
|
||||
if squared_button(ui, "Disk (Not recommended on SSDs)", self.replay_storage == "disk", accent) {
|
||||
self.replay_storage = "disk".to_string();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut nbg = self.name_by_game;
|
||||
if toggle_switch(ui, &mut nbg, accent).changed() {
|
||||
self.name_by_game = nbg;
|
||||
}
|
||||
ui.label(egui::RichText::new("Name the video file after the game").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut rrs = self.restart_replay_on_save;
|
||||
if toggle_switch(ui, &mut rrs, accent).changed() {
|
||||
|
|
@ -2358,46 +2438,7 @@ impl ScytheOverlayApp {
|
|||
|
||||
ui.add_space(10.0);
|
||||
|
||||
render_section_card(ui, "Audio", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Audio capture source mode:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let modes = [("system", "System Audio"), ("mic", "Microphone"), ("both", "Both"), ("muted", "Muted")];
|
||||
for (idx, (_, m_label)) in modes.iter().enumerate() {
|
||||
if squared_button(ui, m_label, self.audio_mode_idx == idx, accent) {
|
||||
self.audio_mode_idx = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Audio codec:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
if squared_button(ui, "Opus (Recommended)", self.config.audio_codec == "opus", accent) {
|
||||
self.config.audio_codec = "opus".to_string();
|
||||
}
|
||||
if squared_button(ui, "AAC", self.config.audio_codec == "aac", accent) {
|
||||
self.config.audio_codec = "aac".to_string();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Sys Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut sv = self.system_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut sv, 0..=150).suffix("%")).changed() {
|
||||
self.system_volume_pct = sv;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Mic Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut mv = self.mic_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut mv, 0..=150).suffix("%")).changed() {
|
||||
self.mic_volume_pct = mv;
|
||||
}
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
render_vu_meter(ui, self.sys_vu, ui.available_width() - 8.0, 10.0, "SYS");
|
||||
});
|
||||
self.render_audio_controls(ui, accent);
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
|
|
@ -2434,7 +2475,8 @@ impl ScytheOverlayApp {
|
|||
self.show_cursor = cur;
|
||||
self.config.show_cursor = cur;
|
||||
let _ = self.config.save();
|
||||
async_send_command(Command::ToggleCursor);
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
self.show_hud_notification("CURSOR", if cur { "Cursor will be recorded" } else { "Cursor hidden" }, crate::overlay::ToastIcon::Cursor);
|
||||
}
|
||||
ui.label(egui::RichText::new("Record cursor").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
|
|
@ -2450,133 +2492,43 @@ impl ScytheOverlayApp {
|
|||
}
|
||||
ui.label(egui::RichText::new("Show replay notifications").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut led_on = self.show_scroll_lock_led;
|
||||
if toggle_switch(ui, &mut led_on, accent).changed() {
|
||||
self.show_scroll_lock_led = led_on;
|
||||
}
|
||||
ui.label(egui::RichText::new("Show replay status with scroll lock LED").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
render_section_card(ui, "Autostart", accent, |ui| {
|
||||
let neither = !self.autostart_replay && !self.autostart_on_game;
|
||||
let btn_w = ui.available_width() - 8.0;
|
||||
if squared_button_sized(ui, "Don't turn on replay automatically", neither, accent, Some(Vec2::new(btn_w, 26.0))) {
|
||||
self.autostart_replay = false;
|
||||
self.autostart_on_game = false;
|
||||
ui.horizontal(|ui| {
|
||||
let mut ar = self.autostart_replay;
|
||||
if toggle_switch(ui, &mut ar, accent).changed() {
|
||||
self.autostart_replay = ar;
|
||||
self.config.autostart_replay = ar;
|
||||
self.config.autostart = ar;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::apply_autostart_system(ar, self.autostart_overlay);
|
||||
self.show_hud_notification("AUTOSTART", if ar { "Replay starts on login" } else { "Replay autostart off" }, crate::overlay::ToastIcon::Info);
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
if squared_button_sized(ui, "Turn on replay when this program starts", self.autostart_replay, accent, Some(Vec2::new(btn_w, 26.0))) {
|
||||
self.autostart_replay = true;
|
||||
self.autostart_on_game = false;
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
if squared_button_sized(ui, "Turn on replay when starting a game", self.autostart_on_game, accent, Some(Vec2::new(btn_w, 26.0))) {
|
||||
self.autostart_on_game = true;
|
||||
self.autostart_replay = false;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Start replay buffer on login").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut pwr_only = self.autostart_power_only;
|
||||
if toggle_switch(ui, &mut pwr_only, accent).changed() {
|
||||
self.autostart_power_only = pwr_only;
|
||||
self.config.autostart_power_only = pwr_only;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
let on_ac = ScytheConfig::is_ac_power_connected();
|
||||
self.show_hud_notification("POWER", if on_ac { "AC supply detected" } else { "On battery: replay paused" }, crate::overlay::ToastIcon::Info);
|
||||
}
|
||||
ui.label(egui::RichText::new("Only turn on replay if a power supply is connected").size(11.5).color(Color32::WHITE));
|
||||
ui.label(egui::RichText::new("Only buffer replay when AC power is connected").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new(if ScytheConfig::is_ac_power_connected() { "status: ac connected" } else { "status: on battery" }).font(FontId::monospace(10.0)).color(Color32::from_rgb(140, 150, 165)));
|
||||
});
|
||||
}
|
||||
|
||||
fn render_tab_record(&mut self, ui: &mut egui::Ui, accent: Color32) {
|
||||
render_section_card(ui, "Capture", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Capture source:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
if squared_button(ui, "Focused monitor", self.capture_source == "focused", accent) {
|
||||
self.capture_source = "focused".to_string();
|
||||
}
|
||||
if squared_button(ui, "Monitor eDP-1 (1920x1080)", self.capture_source == "monitor", accent) {
|
||||
self.capture_source = "monitor".to_string();
|
||||
}
|
||||
if squared_button(ui, "Window", self.capture_source == "window", accent) {
|
||||
self.capture_source = "window".to_string();
|
||||
}
|
||||
if squared_button(ui, "Region", self.capture_source == "region", accent) {
|
||||
self.capture_source = "region".to_string();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut chg_res = self.change_resolution;
|
||||
if toggle_switch(ui, &mut chg_res, accent).changed() {
|
||||
self.change_resolution = chg_res;
|
||||
}
|
||||
ui.label(egui::RichText::new("Change video resolution").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
|
||||
if self.change_resolution {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Resolution:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
let mut w = self.resolution_w;
|
||||
if numeric_input_box(ui, &mut self.res_w_str, &mut w, 640, 7680, 160, "W", accent) {
|
||||
self.resolution_w = w;
|
||||
}
|
||||
ui.label(egui::RichText::new("x").size(11.5).color(Color32::WHITE));
|
||||
let mut h = self.resolution_h;
|
||||
if numeric_input_box(ui, &mut self.res_h_str, &mut h, 480, 4320, 90, "H", accent) {
|
||||
self.resolution_h = h;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
render_section_card(ui, "Audio", accent, |ui| {
|
||||
ui.label(egui::RichText::new("Audio capture source mode:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let modes = [("system", "System Audio"), ("mic", "Microphone"), ("both", "Both"), ("muted", "Muted")];
|
||||
for (idx, (_, m_label)) in modes.iter().enumerate() {
|
||||
if squared_button(ui, m_label, self.audio_mode_idx == idx, accent) {
|
||||
self.audio_mode_idx = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Audio codec:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
if squared_button(ui, "Opus (Recommended)", self.config.audio_codec == "opus", accent) {
|
||||
self.config.audio_codec = "opus".to_string();
|
||||
}
|
||||
if squared_button(ui, "AAC", self.config.audio_codec == "aac", accent) {
|
||||
self.config.audio_codec = "aac".to_string();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Sys Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut sv = self.system_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut sv, 0..=150).suffix("%")).changed() {
|
||||
self.system_volume_pct = sv;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Mic Vol:").size(11.5).color(Color32::WHITE));
|
||||
let mut mv = self.mic_volume_pct;
|
||||
if ui.add(egui::Slider::new(&mut mv, 0..=150).suffix("%")).changed() {
|
||||
self.mic_volume_pct = mv;
|
||||
}
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
render_vu_meter(ui, self.sys_vu, ui.available_width() - 8.0, 10.0, "SYS");
|
||||
});
|
||||
self.render_audio_controls(ui, accent);
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
|
|
@ -2626,7 +2578,8 @@ impl ScytheOverlayApp {
|
|||
self.show_cursor = cur;
|
||||
self.config.show_cursor = cur;
|
||||
let _ = self.config.save();
|
||||
async_send_command(Command::ToggleCursor);
|
||||
ScytheConfig::notify_daemon_reload();
|
||||
self.show_hud_notification("CURSOR", if cur { "Cursor will be recorded" } else { "Cursor hidden" }, crate::overlay::ToastIcon::Cursor);
|
||||
}
|
||||
ui.label(egui::RichText::new("Record cursor").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
|
|
@ -2649,13 +2602,8 @@ impl ScytheOverlayApp {
|
|||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Container:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
if squared_button(ui, "mp4", self.container_format == "mp4", accent) {
|
||||
self.container_format = "mp4".to_string();
|
||||
}
|
||||
if squared_button(ui, "mkv", self.container_format == "mkv", accent) {
|
||||
self.container_format = "mkv".to_string();
|
||||
}
|
||||
ui.label(egui::RichText::new("Output:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.label(egui::RichText::new("MP4 / H.264 + OPUS").font(FontId::monospace(10.5)).color(Color32::from_rgb(140, 150, 165)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2778,16 +2726,6 @@ impl ScytheOverlayApp {
|
|||
ui.label(egui::RichText::new("Capture mouse cursor in screenshot").size(11.5).color(Color32::WHITE));
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Capture source:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (src_id, label) in [("focused", "Focused monitor"), ("full", "Full desktop")] {
|
||||
if squared_button(ui, label, self.capture_source == src_id, accent) {
|
||||
self.capture_source = src_id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -2996,6 +2934,7 @@ impl ScytheOverlayApp {
|
|||
self.config.autostart_replay = ar;
|
||||
self.config.autostart = ar;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::apply_autostart_system(ar, self.autostart_overlay);
|
||||
if ar {
|
||||
spawn_daemon_process();
|
||||
}
|
||||
|
|
@ -3012,6 +2951,7 @@ impl ScytheOverlayApp {
|
|||
self.autostart_overlay = ao;
|
||||
self.config.autostart_overlay = ao;
|
||||
let _ = self.config.save();
|
||||
ScytheConfig::apply_autostart_system(self.autostart_replay, ao);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -3556,90 +3496,67 @@ fn render_scythe_notification_card(
|
|||
total_dur: f32,
|
||||
anim_alpha: f32,
|
||||
) {
|
||||
let active_color = if icon == crate::overlay::ToastIcon::Record || icon == crate::overlay::ToastIcon::Error {
|
||||
Color32::from_rgb(239, 68, 68)
|
||||
} else {
|
||||
accent
|
||||
let active_color = match icon {
|
||||
crate::overlay::ToastIcon::Record | crate::overlay::ToastIcon::Error => Color32::from_rgb(239, 68, 68),
|
||||
crate::overlay::ToastIcon::Save | crate::overlay::ToastIcon::Screenshot => Color32::from_rgb(16, 185, 129),
|
||||
_ => accent,
|
||||
};
|
||||
|
||||
// 1. Entrance accent bloom / pulse highlight during first 0.5s
|
||||
let bloom = if elapsed < 0.50 { (1.0 - elapsed / 0.50).powi(2) } else { 0.0 };
|
||||
if bloom > 0.01 {
|
||||
painter.rect_stroke(
|
||||
rect.expand(2.0),
|
||||
CornerRadius::same(12),
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (bloom * 80.0 * anim_alpha) as u8)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Deep obsidian dark glass card fill (pitch-black aesthetic with smooth rounded corners)
|
||||
// 1. Obsidian panel fill (#111111, zero roundness, no outline, no shadow bloom)
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
CornerRadius::same(10),
|
||||
Color32::from_rgba_unmultiplied(10, 11, 14, (246.0 * anim_alpha) as u8),
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(17, 17, 17, (250.0 * anim_alpha) as u8),
|
||||
);
|
||||
|
||||
// 3. 1.0px smooth border framing the notification card
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(10),
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(42, 45, 52, (180.0 * anim_alpha) as u8)),
|
||||
egui::StrokeKind::Inside,
|
||||
// 4. Functional 6px status square + 2px accent rule (no glow)
|
||||
let dot_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.left() + 12.0, rect.top() + 14.0),
|
||||
Vec2::new(6.0, 6.0),
|
||||
);
|
||||
|
||||
// 4. Signature top solid 3.0px accent bar across the entire card
|
||||
let top_bar_rect = egui::Rect::from_min_size(rect.left_top(), Vec2::new(rect.width(), 3.0));
|
||||
painter.rect_filled(
|
||||
top_bar_rect,
|
||||
CornerRadius { nw: 9, ne: 9, sw: 0, se: 0 },
|
||||
dot_rect,
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
let left_strip = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.left(), rect.top()),
|
||||
Vec2::new(2.0, rect.height()),
|
||||
);
|
||||
painter.rect_filled(
|
||||
left_strip,
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
|
||||
// 5. Specular sheen line directly beneath the top accent line
|
||||
painter.line_segment(
|
||||
[rect.left_top() + Vec2::new(1.0, 3.5), rect.right_top() + Vec2::new(-1.0, 3.5)],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, (25.0 * anim_alpha) as u8)),
|
||||
);
|
||||
|
||||
// 6. Left square icon badge (38x38 obsidian container with subtle accent border)
|
||||
let badge_size = 38.0_f32;
|
||||
// 5. Square icon cell (28x28 recessed #0e0e0e, no outline)
|
||||
let badge_size = 28.0_f32;
|
||||
let badge_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.left() + 9.0, rect.top() + 9.0),
|
||||
egui::pos2(rect.left() + 26.0, rect.top() + 14.0),
|
||||
Vec2::new(badge_size, badge_size),
|
||||
);
|
||||
painter.rect_filled(
|
||||
badge_rect,
|
||||
CornerRadius::same(7),
|
||||
Color32::from_rgba_unmultiplied(6, 7, 9, (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
badge_rect,
|
||||
CornerRadius::same(7),
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (110.0 * anim_alpha) as u8)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.line_segment(
|
||||
[badge_rect.left_top() + Vec2::new(1.0, 1.0), badge_rect.right_top() + Vec2::new(-1.0, 1.0)],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, (20.0 * anim_alpha) as u8)),
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(14, 14, 14, (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
|
||||
// 7. Render crisp icon centered inside badge
|
||||
// 6. Icon render inside badge
|
||||
let icon_center = badge_rect.center();
|
||||
let icon_tint = Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (255.0 * anim_alpha) as u8);
|
||||
match icon {
|
||||
crate::overlay::ToastIcon::Replay => {
|
||||
draw_replay_icon(painter, icon_center, 11.5, true, active_color);
|
||||
draw_replay_icon(painter, icon_center, 11.0, true, active_color);
|
||||
}
|
||||
crate::overlay::ToastIcon::Record => {
|
||||
draw_record_icon(painter, icon_center, 11.0, true, elapsed);
|
||||
draw_record_icon(painter, icon_center, 10.5, true, elapsed);
|
||||
}
|
||||
crate::overlay::ToastIcon::Save => {
|
||||
painter.add(egui::epaint::PathShape::line(
|
||||
vec![
|
||||
icon_center + Vec2::new(-6.0, 0.5),
|
||||
icon_center + Vec2::new(-2.0, 4.5),
|
||||
icon_center + Vec2::new(6.0, -4.5),
|
||||
icon_center + Vec2::new(-5.5, 0.5),
|
||||
icon_center + Vec2::new(-1.5, 4.5),
|
||||
icon_center + Vec2::new(5.5, -4.5),
|
||||
],
|
||||
Stroke::new(2.0_f32, icon_tint),
|
||||
));
|
||||
|
|
@ -3647,90 +3564,73 @@ fn render_scythe_notification_card(
|
|||
crate::overlay::ToastIcon::Cursor => {
|
||||
painter.add(egui::epaint::PathShape::line(
|
||||
vec![
|
||||
icon_center + Vec2::new(-5.0, -7.0),
|
||||
icon_center + Vec2::new(-5.0, 5.0),
|
||||
icon_center + Vec2::new(-1.5, 2.0),
|
||||
icon_center + Vec2::new(1.5, 7.0),
|
||||
icon_center + Vec2::new(3.5, 6.0),
|
||||
icon_center + Vec2::new(0.5, 1.0),
|
||||
icon_center + Vec2::new(5.0, 1.0),
|
||||
icon_center + Vec2::new(-5.0, -7.0),
|
||||
icon_center + Vec2::new(-4.5, -6.5),
|
||||
icon_center + Vec2::new(-4.5, 4.5),
|
||||
icon_center + Vec2::new(-1.5, 1.5),
|
||||
icon_center + Vec2::new(1.5, 6.5),
|
||||
icon_center + Vec2::new(3.5, 5.5),
|
||||
icon_center + Vec2::new(0.5, 0.5),
|
||||
icon_center + Vec2::new(4.5, 0.5),
|
||||
icon_center + Vec2::new(-4.5, -6.5),
|
||||
],
|
||||
Stroke::new(1.8_f32, icon_tint),
|
||||
Stroke::new(1.6_f32, icon_tint),
|
||||
));
|
||||
}
|
||||
crate::overlay::ToastIcon::Screenshot => {
|
||||
let half_w = 7.0;
|
||||
let half_h = 5.0;
|
||||
let half_w = 6.5;
|
||||
let half_h = 4.5;
|
||||
let cam_rect = egui::Rect::from_center_size(icon_center + Vec2::new(0.0, 1.0), Vec2::new(half_w * 2.0, half_h * 2.0));
|
||||
painter.rect_stroke(cam_rect, CornerRadius::ZERO, Stroke::new(1.8_f32, icon_tint), egui::StrokeKind::Inside);
|
||||
painter.circle_stroke(icon_center + Vec2::new(0.0, 1.0), 2.5, Stroke::new(1.6_f32, icon_tint));
|
||||
let notch = egui::Rect::from_min_size(icon_center + Vec2::new(-3.5, -half_h - 1.5), Vec2::new(4.0, 2.5));
|
||||
painter.rect_stroke(cam_rect, CornerRadius::ZERO, Stroke::new(1.6_f32, icon_tint), egui::StrokeKind::Inside);
|
||||
painter.circle_stroke(icon_center + Vec2::new(0.0, 1.0), 2.2, Stroke::new(1.5_f32, icon_tint));
|
||||
let notch = egui::Rect::from_min_size(icon_center + Vec2::new(-3.0, -half_h - 1.2), Vec2::new(3.5, 2.0));
|
||||
painter.rect_filled(notch, CornerRadius::ZERO, icon_tint);
|
||||
}
|
||||
crate::overlay::ToastIcon::Error => {
|
||||
let stroke = Stroke::new(2.0_f32, Color32::from_rgba_unmultiplied(239, 68, 68, (255.0 * anim_alpha) as u8));
|
||||
painter.line_segment([icon_center + Vec2::new(-5.0, -5.0), icon_center + Vec2::new(5.0, 5.0)], stroke);
|
||||
painter.line_segment([icon_center + Vec2::new(5.0, -5.0), icon_center + Vec2::new(-5.0, 5.0)], stroke);
|
||||
painter.line_segment([icon_center + Vec2::new(-4.5, -4.5), icon_center + Vec2::new(4.5, 4.5)], stroke);
|
||||
painter.line_segment([icon_center + Vec2::new(4.5, -4.5), icon_center + Vec2::new(-4.5, 4.5)], stroke);
|
||||
}
|
||||
crate::overlay::ToastIcon::Info => {
|
||||
draw_scythe_icon(painter, icon_center, 20.0, active_color);
|
||||
draw_scythe_icon(painter, icon_center, 18.0, active_color);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Typography Stack
|
||||
let text_left = badge_rect.right() + 12.0;
|
||||
// 7. Terminal type stack: micro-uppercase title, lowercase mono detail
|
||||
let text_left = badge_rect.right() + 10.0;
|
||||
painter.text(
|
||||
egui::pos2(text_left, rect.top() + 19.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
title,
|
||||
FontId::proportional(13.0),
|
||||
Color32::from_rgba_unmultiplied(250, 250, 252, (255.0 * anim_alpha) as u8),
|
||||
title.to_uppercase(),
|
||||
FontId::monospace(11.0),
|
||||
Color32::from_rgba_unmultiplied(240, 240, 240, (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(text_left, rect.top() + 37.0),
|
||||
egui::pos2(text_left, rect.top() + 36.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
subtitle,
|
||||
FontId::proportional(11.0),
|
||||
Color32::from_rgba_unmultiplied(170, 175, 185, (235.0 * anim_alpha) as u8),
|
||||
subtitle.to_lowercase(),
|
||||
FontId::monospace(10.0),
|
||||
Color32::from_rgba_unmultiplied(136, 136, 136, (230.0 * anim_alpha) as u8),
|
||||
);
|
||||
|
||||
// 9. Animated Countdown Timer Line (bottom edge - vibrant accent glow)
|
||||
// 8. Minimal 2px square progress hairline (flat, no glow, no tip)
|
||||
let progress = 1.0 - (elapsed / total_dur).clamp(0.0, 1.0);
|
||||
let bar_h = 3.0_f32;
|
||||
let bar_w = (rect.width() * progress).max(0.0);
|
||||
|
||||
// Baseline track
|
||||
let bar_track = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - bar_h), egui::vec2(rect.width(), bar_h));
|
||||
painter.rect_filled(bar_track, CornerRadius::ZERO, Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (35.0 * anim_alpha) as u8));
|
||||
|
||||
// Ambient vibrant glow above progress bar
|
||||
let glow_h = 2.5_f32;
|
||||
let glow_rect = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - bar_h - glow_h), egui::vec2(bar_w, glow_h));
|
||||
painter.rect_filled(glow_rect, CornerRadius::ZERO, Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (55.0 * anim_alpha) as u8));
|
||||
|
||||
// Solid vibrant core bar
|
||||
let bar_rect = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - bar_h), egui::vec2(bar_w, bar_h));
|
||||
painter.rect_filled(bar_rect, CornerRadius::ZERO, Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (255.0 * anim_alpha) as u8));
|
||||
|
||||
// Top neon specular edge highlight for intense vibrancy
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.left(), rect.bottom() - bar_h), egui::pos2(rect.left() + bar_w, rect.bottom() - bar_h)],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(
|
||||
(active_color.r() as u16 + 80).min(255) as u8,
|
||||
(active_color.g() as u16 + 80).min(255) as u8,
|
||||
(active_color.b() as u16 + 80).min(255) as u8,
|
||||
(240.0 * anim_alpha) as u8,
|
||||
)),
|
||||
let bar_h = 2.0_f32;
|
||||
let track_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.left() + 2.0, rect.bottom() - bar_h),
|
||||
Vec2::new(rect.width() - 2.0, bar_h),
|
||||
);
|
||||
|
||||
// Laser-sharp white leading tip indicator
|
||||
if bar_w > 2.0 {
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.left() + bar_w, rect.bottom() - bar_h - 1.0), egui::pos2(rect.left() + bar_w, rect.bottom())],
|
||||
Stroke::new(2.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, (255.0 * anim_alpha) as u8)),
|
||||
painter.rect_filled(
|
||||
track_rect,
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(28, 28, 28, (220.0 * anim_alpha) as u8),
|
||||
);
|
||||
let bar_w = ((rect.width() - 2.0) * progress).max(0.0);
|
||||
let bar_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + 2.0, rect.bottom() - bar_h), egui::vec2(bar_w, bar_h));
|
||||
painter.rect_filled(
|
||||
bar_rect,
|
||||
CornerRadius::ZERO,
|
||||
Color32::from_rgba_unmultiplied(active_color.r(), active_color.g(), active_color.b(), (255.0 * anim_alpha) as u8),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for ScytheOverlayApp {
|
||||
|
|
@ -4434,15 +4334,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_overlay_app_options_synchronization() {
|
||||
let mut app = ScytheOverlayApp::new();
|
||||
app.container_format = "mkv".to_string();
|
||||
app.change_resolution = true;
|
||||
app.resolution_w = 2560;
|
||||
app.resolution_h = 1440;
|
||||
app.save_folder_by_game = true;
|
||||
app.name_by_game = true;
|
||||
app.restart_replay_on_save = false;
|
||||
app.show_scroll_lock_led = true;
|
||||
app.capture_source = "window".to_string();
|
||||
app.video_quality_mode = "vbr".to_string();
|
||||
app.color_range = "full".to_string();
|
||||
app.config.audio_codec = "opus".to_string();
|
||||
|
|
@ -4452,15 +4344,7 @@ mod tests {
|
|||
|
||||
app.save_and_apply_settings();
|
||||
|
||||
assert_eq!(app.config.container_format, "mkv");
|
||||
assert!(app.config.change_resolution);
|
||||
assert_eq!(app.config.resolution_w, 2560);
|
||||
assert_eq!(app.config.resolution_h, 1440);
|
||||
assert!(app.config.save_folder_by_game);
|
||||
assert!(app.config.name_by_game);
|
||||
assert!(!app.config.restart_replay_on_save);
|
||||
assert!(app.config.show_scroll_lock_led);
|
||||
assert_eq!(app.config.capture_source, "window");
|
||||
assert_eq!(app.config.video_quality_mode, "vbr");
|
||||
assert_eq!(app.config.color_range, "full");
|
||||
assert_eq!(app.config.audio_codec, "opus");
|
||||
|
|
@ -4469,13 +4353,7 @@ mod tests {
|
|||
assert_eq!(app.config.screenshot_resolution, "1080p");
|
||||
|
||||
app.reset_settings_to_defaults();
|
||||
assert_eq!(app.container_format, "mp4");
|
||||
assert!(!app.change_resolution);
|
||||
assert!(!app.save_folder_by_game);
|
||||
assert!(!app.name_by_game);
|
||||
assert!(app.restart_replay_on_save);
|
||||
assert!(!app.show_scroll_lock_led);
|
||||
assert_eq!(app.capture_source, "focused");
|
||||
assert_eq!(app.video_quality_mode, "cbr");
|
||||
assert_eq!(app.color_range, "limited");
|
||||
assert_eq!(app.config.audio_codec, "opus");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,46 @@ echo "[1/6] Building release binaries..."
|
|||
cargo build --release --quiet
|
||||
|
||||
SOCKET_PATH="${XDG_RUNTIME_DIR:-/tmp}/scythe.sock"
|
||||
|
||||
# Liveness probe with no side effects. `scythe --status` calls
|
||||
# ensure_daemon_running(), so probing with it spawns a daemon and the check
|
||||
# starts chasing its own tail.
|
||||
daemon_alive() {
|
||||
[ -S "$SOCKET_PATH" ] || return 1
|
||||
python3 -c '
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(0.5)
|
||||
try:
|
||||
s.connect(sys.argv[1])
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
s.close()
|
||||
' "$SOCKET_PATH" 2>/dev/null
|
||||
}
|
||||
|
||||
# A live daemon owns this socket. Removing the path entry while it runs leaves the
|
||||
# kernel socket bound but unreachable, breaking the user's session until restart.
|
||||
# Stop it gracefully, remember how it ran, and put it back when the test finishes.
|
||||
PREVIOUS_DAEMON_CMD=""
|
||||
PREVIOUS_DAEMON_CWD=""
|
||||
if daemon_alive; then
|
||||
PREVIOUS_DAEMON_CMD=$(pgrep -af 'scythe.*daemon' 2>/dev/null | head -1 | cut -d' ' -f2- || true)
|
||||
PREVIOUS_DAEMON_CWD=$(readlink -f /proc/"$(pgrep -f 'scythe.*daemon' 2>/dev/null | head -1)"/cwd 2>/dev/null || true)
|
||||
echo "[0/6] Existing daemon detected; stopping it for the test run..."
|
||||
target/release/scythe --quit >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
daemon_alive || break
|
||||
sleep 0.1
|
||||
done
|
||||
if daemon_alive; then
|
||||
echo "ERROR: A scythe daemon is running and did not stop cleanly."
|
||||
echo " Refusing to unlink its socket. Stop it first: scythe --quit"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$SOCKET_PATH"
|
||||
rm -f record_*.mp4 replay_*.mp4
|
||||
|
||||
|
|
@ -23,17 +63,21 @@ if systemctl --user is-active --quiet scythe-daemon.service 2>/dev/null; then
|
|||
sleep 0.5
|
||||
fi
|
||||
|
||||
echo "[2/6] Starting scythe-daemon with mock capture..."
|
||||
target/release/scythe-daemon --mock > daemon_test.log 2>&1 &
|
||||
echo "[2/6] Starting scythe daemon with mock capture..."
|
||||
target/release/scythe daemon --mock > daemon_test.log 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if kill -0 $DAEMON_PID 2>/dev/null; then
|
||||
target/release/scythe-ui --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true
|
||||
target/release/scythe --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true
|
||||
fi
|
||||
rm -f daemon_test.log
|
||||
if [ "$WAS_SERVICE_ACTIVE" -eq 1 ]; then
|
||||
systemctl --user start scythe-daemon.service 2>/dev/null || true
|
||||
elif [ -n "$PREVIOUS_DAEMON_CMD" ]; then
|
||||
echo "Restoring the daemon that was running before the test..."
|
||||
( cd "${PREVIOUS_DAEMON_CWD:-$PWD}" && setsid $PREVIOUS_DAEMON_CMD >/dev/null 2>&1 & ) || true
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
|
@ -56,23 +100,23 @@ echo "Daemon is live (PID: $DAEMON_PID, Socket: $SOCKET_PATH)"
|
|||
|
||||
# Test 1: Start Normal Recording
|
||||
echo "[3/6] Testing: Start Normal Recording..."
|
||||
target/release/scythe-ui --start
|
||||
target/release/scythe --start
|
||||
echo "Recording in progress for 3 seconds..."
|
||||
sleep 3
|
||||
|
||||
# Test 2: Stop Normal Recording
|
||||
echo "[4/6] Testing: Stop Normal Recording..."
|
||||
target/release/scythe-ui --stop
|
||||
target/release/scythe --stop
|
||||
sleep 1
|
||||
|
||||
# Test 3: Save Instant Replay
|
||||
echo "[5/6] Testing: Save Instant Replay buffer..."
|
||||
target/release/scythe-ui --save
|
||||
target/release/scythe --save
|
||||
sleep 2
|
||||
|
||||
# Test 4: Quit Daemon
|
||||
echo "[6/6] Testing: Clean Daemon Shutdown..."
|
||||
target/release/scythe-ui --quit
|
||||
target/release/scythe --quit
|
||||
wait $DAEMON_PID 2>/dev/null || true
|
||||
echo "Daemon shutdown cleanly."
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue