From aa17868c2c444cdd42213df4bec113ac1d3feea9 Mon Sep 17 00:00:00 2001 From: oluiscabral <58452540+oluiscabral@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:14:00 -0300 Subject: [PATCH 01/22] [ARK Drop] Enhancements and TUI (#106) * WIP(cli/tui): implement tui * feat(arkdrop): implement tui and arkdrop main * fix(arkdrop): fix release version pattern * fix(arkdrop): fix set default output directory handling * style(arkdrop): reduce qr-code dimensions * fix(arkdrop): reduce tui qr-code display and fix integration tests * perf(workflows): remove unnecessary trigger * style(arkdrop): fix qr-code dark color character * style(arkdrop): reduce qr-code display dimensions * feat(arkdrop): update tui key handling * fix(arkdrop): close success and error dialogs * fix: adjust help navigation * fix: start file receiving * style: reduce arkdrop qr-code size * style(arkdrop): update qr-code presentation * wip: prepare tui implementation * wip: prepare tui implementation 2 * wip: tui 00 * wip: file browser as page * wip: modularization * wip: refactored home page * wip: send files integration * wip: improving TODO logs * wip: improving docs * wip: layout helper footer * wip: fix send_files back navigation and helper footer text * wip: fix file browser navigation * wip: modular send files manager * wip: send files progress page * wip: iteration on send files progress page * wip: optional footer * wip: receive files impl * wip: receive files progress * wip: add instructions * wip: bubble management * wip: send files progress update * wip: improve send files progress * wip: improve send files * wip: receive files and receive files progress update * feat: TUI improved implementation * wip: update config handle control * style: general ui updates * fix: proper create receive files request * fix: home greeting text * fix: dead lock issue * fix: file browser dead lock issue * fix: handle refresh on draw * feat: allow paste ticket and confirmation receiving files * fix: receive files ticket and confirmation input * fix: enrich text inputs * fix: layout quit keybind * fix: navigation after cancelling * fix: start bubble on receive files manager * fix: handle cancelled operations * fix: receive files progress * style: show ticket AND confirmation * fix: stop elapsed time after finishing transfer * chore: remove unnecessary TODO comments * chore: remove unused property/methods * chore: remove out dir field from receive files * fix: set sender name * feat: send files qr code and send/receive reset state * style: update qr-code presentation * style: improve UI for send files * style: new line before QR Code info * test: improve integration tests * refactor: remove unused functions --- ...l => arkdrop-android-bindings-release.yml} | 12 +- .../workflows/arkdrop-integration-test.yml | 429 ++++++ ...op-cli-release.yml => arkdrop-release.yml} | 59 +- Cargo.toml | 14 +- drop-core/cli/Cargo.toml | 37 +- drop-core/cli/README.md | 35 +- drop-core/cli/src/lib.rs | 751 +++++----- drop-core/cli/src/main.rs | 318 +---- drop-core/cli/tests/integration_tests.rs | 243 ++++ drop-core/cli/tests/network_simulation.rs | 375 +++++ drop-core/common/Cargo.toml | 17 + drop-core/common/src/lib.rs | 530 +++++++ drop-core/entities/Cargo.toml | 4 +- drop-core/entities/src/data.rs | 7 + drop-core/entities/src/lib.rs | 8 +- drop-core/exchanges/common/Cargo.toml | 4 +- drop-core/exchanges/common/src/handshake.rs | 2 +- drop-core/exchanges/receiver/Cargo.toml | 8 +- drop-core/exchanges/receiver/src/lib.rs | 95 -- .../exchanges/receiver/src/receive_files.rs | 96 +- drop-core/exchanges/sender/Cargo.toml | 8 +- drop-core/exchanges/sender/src/lib.rs | 15 +- drop-core/exchanges/sender/src/send_files.rs | 38 +- .../sender/src/send_files/handler.rs | 81 +- drop-core/main/Cargo.toml | 18 + drop-core/main/src/main.rs | 15 + drop-core/tui/Cargo.toml | 35 + drop-core/tui/src/apps/config.rs | 924 ++++++++++++ drop-core/tui/src/apps/file_browser.rs | 744 ++++++++++ drop-core/tui/src/apps/help.rs | 387 +++++ drop-core/tui/src/apps/home.rs | 473 ++++++ drop-core/tui/src/apps/mod.rs | 8 + drop-core/tui/src/apps/receive_files.rs | 1272 +++++++++++++++++ .../tui/src/apps/receive_files_progress.rs | 761 ++++++++++ drop-core/tui/src/apps/send_files.rs | 1266 ++++++++++++++++ drop-core/tui/src/apps/send_files_progress.rs | 776 ++++++++++ drop-core/tui/src/backend.rs | 96 ++ drop-core/tui/src/layout.rs | 406 ++++++ drop-core/tui/src/lib.rs | 276 ++++ drop-core/tui/src/main.rs | 7 + drop-core/tui/src/receive_files_manager.rs | 68 + drop-core/tui/src/send_files_manager.rs | 64 + drop-core/tui/src/utilities/helper_footer.rs | 63 + drop-core/tui/src/utilities/mod.rs | 1 + drop-core/uniffi/Cargo.toml | 8 +- drop-core/uniffi/src/drop.udl | 3 + drop-core/uniffi/src/receiver.rs | 2 +- .../uniffi/src/receiver/receive_files.rs | 26 +- drop-core/uniffi/src/sender.rs | 13 +- drop-core/uniffi/src/sender/send_files.rs | 33 +- drop-core/uniffi/uniffi.toml | 2 +- 51 files changed, 9931 insertions(+), 1002 deletions(-) rename .github/workflows/{drop-android-bindings-release.yml => arkdrop-android-bindings-release.yml} (83%) create mode 100644 .github/workflows/arkdrop-integration-test.yml rename .github/workflows/{drop-cli-release.yml => arkdrop-release.yml} (71%) create mode 100644 drop-core/cli/tests/integration_tests.rs create mode 100644 drop-core/cli/tests/network_simulation.rs create mode 100644 drop-core/common/Cargo.toml create mode 100644 drop-core/common/src/lib.rs create mode 100644 drop-core/main/Cargo.toml create mode 100644 drop-core/main/src/main.rs create mode 100644 drop-core/tui/Cargo.toml create mode 100644 drop-core/tui/src/apps/config.rs create mode 100644 drop-core/tui/src/apps/file_browser.rs create mode 100644 drop-core/tui/src/apps/help.rs create mode 100644 drop-core/tui/src/apps/home.rs create mode 100644 drop-core/tui/src/apps/mod.rs create mode 100644 drop-core/tui/src/apps/receive_files.rs create mode 100644 drop-core/tui/src/apps/receive_files_progress.rs create mode 100644 drop-core/tui/src/apps/send_files.rs create mode 100644 drop-core/tui/src/apps/send_files_progress.rs create mode 100644 drop-core/tui/src/backend.rs create mode 100644 drop-core/tui/src/layout.rs create mode 100644 drop-core/tui/src/lib.rs create mode 100644 drop-core/tui/src/main.rs create mode 100644 drop-core/tui/src/receive_files_manager.rs create mode 100644 drop-core/tui/src/send_files_manager.rs create mode 100644 drop-core/tui/src/utilities/helper_footer.rs create mode 100644 drop-core/tui/src/utilities/mod.rs diff --git a/.github/workflows/drop-android-bindings-release.yml b/.github/workflows/arkdrop-android-bindings-release.yml similarity index 83% rename from .github/workflows/drop-android-bindings-release.yml rename to .github/workflows/arkdrop-android-bindings-release.yml index c4a0301b..7e165b45 100644 --- a/.github/workflows/drop-android-bindings-release.yml +++ b/.github/workflows/arkdrop-android-bindings-release.yml @@ -1,9 +1,12 @@ -name: Drop Android Bindings Release +name: ARK Drop Android Bindings Release on: push: paths: - - "drop-core/**" + - "drop-core/entities/**" + - "drop-core/exchanges/**" + - "drop-core/uniffi/**" + - ".github/workflows/arkdrop-bindings-release.yml" workflow_dispatch: jobs: @@ -47,11 +50,8 @@ jobs: link-to-sdk: true ndk-version: r29-beta2 - - name: Set RELEASE_VERSION - run: echo "RELEASE_VERSION=$GITHUB_RUN_ID" >> $GITHUB_ENV - - name: Publish Bindings Android run: ./gradlew publish -Pandroid.useAndroidX=true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + RELEASE_VERSION: ${{ github.run_id }} diff --git a/.github/workflows/arkdrop-integration-test.yml b/.github/workflows/arkdrop-integration-test.yml new file mode 100644 index 00000000..2f95743b --- /dev/null +++ b/.github/workflows/arkdrop-integration-test.yml @@ -0,0 +1,429 @@ +name: ARK Drop Integration Tests + +on: + push: + paths: + - "drop-core/entities/**" + - "drop-core/exchanges/**" + - "drop-core/cli/**" + - "drop-core/tui/**" + - "drop-core/main/**" + - ".github/workflows/arkdrop-integration-test.yml" + workflow_dispatch: + +jobs: + test-file-scenarios: + name: File Transfer Scenarios + runs-on: ubuntu-latest + strategy: + matrix: + scenario: + - name: small-text + files: "small.txt" + size: "1K" + type: "text" + - name: large-binary + files: "large.bin" + size: "100M" + type: "binary" + - name: multiple-mixed + files: "doc.pdf image.jpg data.csv" + size: "mixed" + type: "mixed" + - name: unicode-names + files: "文件.txt файл.doc αρχείο.pdf" + size: "1K" + type: "unicode" + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Cache cargo + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Extract github.run_id version + id: extract_version + run: | + RUN_ID="$GITHUB_RUN_ID" + VERSION=$(echo "$RUN_ID" | sed -r 's/([0-9]{1,3})([0-9]{1,3})?([0-9]{1,3})?/\1.\2.\3/' | sed 's/\.$//' | sed 's/\.\./\./g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build ARK Drop + run: | + cargo build -p arkdrop --release + # Verify binary was created + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + echo "✅ Binary built successfully at target/release/arkdrop (v${{ steps.extract_version.outputs.version }})" + + - name: Generate test files for ${{ matrix.scenario.name }} + run: | + mkdir -p test-files-${{ matrix.scenario.name }} + + case "${{ matrix.scenario.type }}" in + text) + for file in ${{ matrix.scenario.files }}; do + head -c ${{ matrix.scenario.size }} /dev/urandom | base64 > "test-files-${{ matrix.scenario.name }}/$file" + done + ;; + binary) + for file in ${{ matrix.scenario.files }}; do + dd if=/dev/urandom of="test-files-${{ matrix.scenario.name }}/$file" bs=${{ matrix.scenario.size }} count=1 + done + ;; + mixed) + echo "PDF content" > "test-files-${{ matrix.scenario.name }}/doc.pdf" + echo "JPG content" > "test-files-${{ matrix.scenario.name }}/image.jpg" + echo "CSV,data,here" > "test-files-${{ matrix.scenario.name }}/data.csv" + ;; + unicode) + for file in ${{ matrix.scenario.files }}; do + echo "Unicode test content" > "test-files-${{ matrix.scenario.name }}/$file" + done + ;; + esac + + - name: Test ARK Drop functionality - ${{ matrix.scenario.name }} + timeout-minutes: 15 + run: | + # Verify binary exists + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + + # Test ARK Drop commands and file validation (help commands only - non-blocking) + ./target/release/arkdrop --help + ./target/release/arkdrop send --help + ./target/release/arkdrop receive --help + + # Validate that files exist (without running the actual send command) + echo "📋 Validating test files for scenario ${{ matrix.scenario.name }}:" + for file in ${{ matrix.scenario.files }}; do + file_path="test-files-${{ matrix.scenario.name }}/$file" + if [ -f "$file_path" ]; then + echo " ✅ $file_path exists" + else + echo " ❌ $file_path missing" + exit 1 + fi + done + + echo "✅ ARK Drop help commands work and files validated for ${{ matrix.scenario.name }}" + + # Test error handling and edge cases + test-error-handling: + name: Error Handling Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Extract github.run_id version + id: extract_version + run: | + # Split github.run_id into groups of 3 digits + RUN_ID="$GITHUB_RUN_ID" + VERSION=$(echo "$RUN_ID" | sed -r 's/([0-9]{1,3})([0-9]{1,3})?([0-9]{1,3})?/\1.\2.\3/' | sed 's/\.$//' | sed 's/\.\./\./g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build ARK Drop + run: | + cargo build -p arkdrop --release + # Verify binary was created + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + echo "✅ Binary built successfully at target/release/arkdrop (v${{ steps.extract_version.outputs.version }})" + + - name: Test missing file error + run: | + # Verify binary exists first + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + + # Test missing file validation (this should fail early before any network operations) + if ./target/release/arkdrop send --name "Test" nonexistent.txt 2>&1 | grep -qi "File does not exist\|not found\|does not exist"; then + echo "✅ Missing file error handled correctly" + else + echo "❌ Missing file error not handled properly" + exit 1 + fi + + - name: Test command structure + run: | + # Create a test file + echo "test" > test.txt + + # Test command help (non-blocking help command) + ./target/release/arkdrop send --help | grep -q "Send files" + echo "✅ ARK Drop shows proper usage information" + + - name: Test output directory handling + run: | + # Test config commands for directory management (these are local operations, no network) + ./target/release/arkdrop config show + + # Test setting receive directory + mkdir -p /tmp/test-output + ./target/release/arkdrop config set-output /tmp/test-output + + # Verify it was set + if ./target/release/arkdrop config show | grep -q "/tmp/test-output"; then + echo "✅ Directory configuration working" + else + echo "❌ Directory configuration not working" + exit 1 + fi + + # Clear the directory + ./target/release/arkdrop config clear-output + + # Cleanup + rm -rf /tmp/test-output + + - name: Test ARK Drop signal handling + timeout-minutes: 2 + run: | + # Test that ARK Drop responds to basic commands (non-blocking) + echo "Testing ARK Drop responsiveness..." + + # Test version and help commands (these don't block) + ./target/release/arkdrop --version + ./target/release/arkdrop --help | head -10 + + echo "✅ ARK Drop signal handling test completed" + + # Test concurrent transfers + test-concurrent-transfers: + name: Concurrent Transfer Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Extract github.run_id version + id: extract_version + run: | + # Split github.run_id into groups of 3 digits + RUN_ID="$GITHUB_RUN_ID" + VERSION=$(echo "$RUN_ID" | sed -r 's/([0-9]{1,3})([0-9]{1,3})?([0-9]{1,3})?/\1.\2.\3/' | sed 's/\.$//' | sed 's/\.\./\./g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build ARK Drop + run: | + cargo build -p arkdrop --release + # Verify binary was created + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + echo "✅ Binary built successfully at target/release/arkdrop (v${{ steps.extract_version.outputs.version }})" + + - name: Test ARK Drop with multiple file scenarios + timeout-minutes: 5 + run: | + # Verify binary exists + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + + # Create test files for different scenarios + for i in {1..3}; do + mkdir -p scenario-$i + echo "Content for scenario $i" > scenario-$i/file$i.txt + dd if=/dev/urandom of=scenario-$i/data$i.bin bs=1K count=10 2>/dev/null + done + + # Test that ARK Drop can detect files without starting transfer (validate files exist) + for i in {1..3}; do + echo "Testing scenario $i file detection..." + # Validate files exist without running the blocking send command + if [ -f "scenario-$i/file$i.txt" ] && [ -f "scenario-$i/data$i.bin" ]; then + echo "✅ Scenario $i files exist and ready for transfer" + else + echo "❌ Scenario $i files missing" + exit 1 + fi + done + + echo "✅ Multiple file scenario tests completed" + + # Performance benchmarks + performance-benchmark: + name: Performance Benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Build ARK Drop + run: | + cargo build -p arkdrop --release + echo "✅ Binary built successfully at target/release/arkdrop" + + - name: Run ARK Drop performance checks + run: | + # Create benchmark results file + echo "# ARK Drop Performance Benchmarks" > benchmark-results.md + echo "" >> benchmark-results.md + echo "| Test | Result |" >> benchmark-results.md + echo "|------|--------|" >> benchmark-results.md + + # Test ARK Drop startup time (help command is non-blocking) + START_TIME=$(date +%s.%N) + ./target/release/arkdrop --help > /dev/null + END_TIME=$(date +%s.%N) + STARTUP_TIME=$(echo "$END_TIME - $START_TIME" | bc -l) + echo "| ARK Drop Startup | ${STARTUP_TIME}s |" >> benchmark-results.md + + # Test file existence validation performance (no network operations) + for size in 1M 10M; do + dd if=/dev/urandom of=benchmark-$size.bin bs=$size count=1 2>/dev/null + + START_TIME=$(date +%s.%N) + # Test that ARK Drop can validate file exists (early validation, no network) + if [ -f "benchmark-$size.bin" ]; then + FILE_SIZE=$(stat -c%s "benchmark-$size.bin" 2>/dev/null || stat -f%z "benchmark-$size.bin" 2>/dev/null) + echo "File validation: $size file exists (${FILE_SIZE} bytes)" + fi + END_TIME=$(date +%s.%N) + + DURATION=$(echo "$END_TIME - $START_TIME" | bc -l) + echo "| File Validation $size | ${DURATION}s |" >> benchmark-results.md + done + + cat benchmark-results.md + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: benchmark-results.md + retention-days: 30 + + # Memory and resource usage tests + test-resource-usage: + name: Resource Usage Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install monitoring tools + run: | + sudo apt-get update + sudo apt-get install -y sysstat valgrind time + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Extract github.run_id version + id: extract_version + run: | + # Split github.run_id into groups of 3 digits + RUN_ID="$GITHUB_RUN_ID" + VERSION=$(echo "$RUN_ID" | sed -r 's/([0-9]{1,3})([0-9]{1,3})?([0-9]{1,3})?/\1.\2.\3/' | sed 's/\.$//' | sed 's/\.\./\./g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build ARK Drop + run: | + cargo build -p arkdrop --release + # Verify binary was created + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + echo "✅ Binary built successfully at target/release/arkdrop (v${{ steps.extract_version.outputs.version }})" + + - name: Monitor resource usage during transfer + timeout-minutes: 10 + run: | + # Verify binary exists + if [ ! -f "target/release/arkdrop" ]; then + echo "❌ Binary not found at target/release/arkdrop" + ls -la target/release/ + exit 1 + fi + + # Create large test file + dd if=/dev/urandom of=resource-test.bin bs=1M count=200 + + # Start resource monitoring + sar -u -r 1 > resource-usage.log 2>&1 & + SAR_PID=$! + + # Monitor basic resource usage for ARK Drop commands (non-blocking operations only) + echo "=== Resource Usage Test ===" + + # Test help command resource usage (non-blocking) + /usr/bin/time -v ./target/release/arkdrop --help > /dev/null 2> help-memory.log + + # Test config command resource usage (non-blocking local operation) + /usr/bin/time -v ./target/release/arkdrop config show > /dev/null 2> config-memory.log || true + + # Stop monitoring + kill $SAR_PID || true + + # Extract and display resource usage + echo "=== Help Command Memory Usage ===" + grep "Maximum resident" help-memory.log || echo "Memory stats not available" + + echo "=== Config Command Memory Usage ===" + grep "Maximum resident" config-memory.log || echo "Memory stats not available" + + # Check for reasonable memory usage + HELP_MEM=$(grep "Maximum resident" help-memory.log | awk '{print $6}' || echo "0") + if [ "$HELP_MEM" -lt "100000" ]; then # Less than 100MB for help + echo "✅ ARK Drop memory usage within acceptable limits" + else + echo "⚠️ High ARK Drop memory usage detected: ${HELP_MEM}KB" + fi diff --git a/.github/workflows/drop-cli-release.yml b/.github/workflows/arkdrop-release.yml similarity index 71% rename from .github/workflows/drop-cli-release.yml rename to .github/workflows/arkdrop-release.yml index c86a8ba4..3f68cbcf 100644 --- a/.github/workflows/drop-cli-release.yml +++ b/.github/workflows/arkdrop-release.yml @@ -1,9 +1,14 @@ -name: Drop CLI Release +name: ARK Drop Release on: push: paths: - - "drop-core/**" + - "drop-core/entities/**" + - "drop-core/exchanges/**" + - "drop-core/cli/**" + - "drop-core/tui/**" + - "drop-core/main/**" + - ".github/workflows/arkdrop-release.yml" workflow_dispatch: jobs: @@ -100,7 +105,7 @@ jobs: - name: Build binary run: | - cargo build --release --target ${{ matrix.target }} --package drop-cli + cargo build -p arkdrop --release --target ${{ matrix.target }} - name: Create archive name id: archive @@ -109,10 +114,10 @@ jobs: if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" else - VERSION="drop-cli-v${{ github.run_id }}" - VERSION="${VERSION#drop-cli-v}" + VERSION="arkdrop-v${{ github.run_id }}" + VERSION="${VERSION#arkdrop-v}" fi - ARCHIVE_NAME="drop-cli-${VERSION}-${{ matrix.target }}" + ARCHIVE_NAME="arkdrop-${VERSION}-${{ matrix.target }}" echo "name=${ARCHIVE_NAME}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT @@ -121,9 +126,9 @@ jobs: run: | cd target/${{ matrix.target }}/release if [ "${{ matrix.target }}" == *"windows"* ]; then - BINARY_NAME="drop-cli.exe" + BINARY_NAME="arkdrop.exe" else - BINARY_NAME="drop-cli" + BINARY_NAME="arkdrop" fi tar -czf ${{ steps.archive.outputs.name }}.tar.gz ${BINARY_NAME} mv ${{ steps.archive.outputs.name }}.tar.gz ${{ github.workspace }}/ @@ -133,7 +138,7 @@ jobs: shell: pwsh run: | cd target/${{ matrix.target }}/release - Compress-Archive -Path "drop-cli.exe" -DestinationPath "${{ github.workspace }}/${{ steps.archive.outputs.name }}.zip" + Compress-Archive -Path "arkdrop.exe" -DestinationPath "${{ github.workspace }}/${{ steps.archive.outputs.name }}.zip" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -154,8 +159,8 @@ jobs: - name: Set version id: version run: | - TAG_NAME="drop-cli-v${{ github.run_id }}" - VERSION="${TAG_NAME#drop-cli-v}" + VERSION="$GITHUB_RUN_ID" + TAG_NAME="arkdrop-v${VERSION}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "tag=${TAG_NAME}" >> $GITHUB_OUTPUT @@ -168,48 +173,48 @@ jobs: id: release_notes run: | cat > release_notes.md << 'EOF' - ## Drop CLI ${{ steps.version.outputs.version }} + ## ARK Drop CLI ${{ steps.version.outputs.version }} ### Downloads Choose the appropriate binary for your platform: #### Linux - - **x86_64 (glibc)**: `drop-cli-${{ steps.version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz` - - **x86_64 (musl)**: `drop-cli-${{ steps.version.outputs.version }}-x86_64-unknown-linux-musl.tar.gz` - - **ARM64 (glibc)**: `drop-cli-${{ steps.version.outputs.version }}-aarch64-unknown-linux-gnu.tar.gz` - - **ARM64 (musl)**: `drop-cli-${{ steps.version.outputs.version }}-aarch64-unknown-linux-musl.tar.gz` - - **ARMv7 (glibc)**: `drop-cli-${{ steps.version.outputs.version }}-armv7-unknown-linux-gnueabihf.tar.gz` - - **ARMv7 (musl)**: `drop-cli-${{ steps.version.outputs.version }}-armv7-unknown-linux-musleabihf.tar.gz` + - **x86_64 (glibc)**: `arkdrop-${{ steps.version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz` + - **x86_64 (musl)**: `arkdrop-${{ steps.version.outputs.version }}-x86_64-unknown-linux-musl.tar.gz` + - **ARM64 (glibc)**: `arkdrop-${{ steps.version.outputs.version }}-aarch64-unknown-linux-gnu.tar.gz` + - **ARM64 (musl)**: `arkdrop-${{ steps.version.outputs.version }}-aarch64-unknown-linux-musl.tar.gz` + - **ARMv7 (glibc)**: `arkdrop-${{ steps.version.outputs.version }}-armv7-unknown-linux-gnueabihf.tar.gz` + - **ARMv7 (musl)**: `arkdrop-${{ steps.version.outputs.version }}-armv7-unknown-linux-musleabihf.tar.gz` #### Windows - - **x86_64**: `drop-cli-${{ steps.version.outputs.version }}-x86_64-pc-windows-msvc.zip` - - **x86**: `drop-cli-${{ steps.version.outputs.version }}-i686-pc-windows-msvc.zip` - - **ARM64**: `drop-cli-${{ steps.version.outputs.version }}-aarch64-pc-windows-msvc.zip` + - **x86_64**: `arkdrop-${{ steps.version.outputs.version }}-x86_64-pc-windows-msvc.zip` + - **x86**: `arkdrop-${{ steps.version.outputs.version }}-i686-pc-windows-msvc.zip` + - **ARM64**: `arkdrop-${{ steps.version.outputs.version }}-aarch64-pc-windows-msvc.zip` #### macOS - - **Intel (x86_64)**: `drop-cli-${{ steps.version.outputs.version }}-x86_64-apple-darwin.tar.gz` - - **Apple Silicon (ARM64)**: `drop-cli-${{ steps.version.outputs.version }}-aarch64-apple-darwin.tar.gz` + - **Intel (x86_64)**: `arkdrop-${{ steps.version.outputs.version }}-x86_64-apple-darwin.tar.gz` + - **Apple Silicon (ARM64)**: `arkdrop-${{ steps.version.outputs.version }}-aarch64-apple-darwin.tar.gz` ### Installation 1. Download the appropriate binary for your platform 2. Extract the archive 3. Move the binary to a location in your PATH (e.g., `/usr/local/bin` on Unix systems) - 4. Make it executable (Unix systems): `chmod +x drop-cli` + 4. Make it executable (Unix systems): `chmod +x arkdrop` ### Usage - Run `drop-cli --help` to see available commands and options. + Run `arkdrop --help` to see available commands and options. EOF - name: Create or update release uses: softprops/action-gh-release@v1 with: tag_name: ${{ steps.version.outputs.tag }} - name: Drop CLI ${{ steps.version.outputs.version }} + name: ARK Drop CLI ${{ steps.version.outputs.version }} body_path: release_notes.md - files: artifacts/drop-cli-*/* + files: artifacts/arkdrop-*/* draft: false prerelease: false env: diff --git a/Cargo.toml b/Cargo.toml index abfa0f87..9e032953 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,15 @@ members = [ "fs-index", "fs-storage", "dev-hash", - "drop-core/cli", + "drop-core/entities", "drop-core/exchanges/common", "drop-core/exchanges/sender", "drop-core/exchanges/receiver", - "drop-core/uniffi", + "drop-core/common", + "drop-core/cli", + "drop-core/tui", + "drop-core/main", + "drop-core/uniffi", ] default-members = [ @@ -34,10 +38,14 @@ default-members = [ "fs-index", "fs-storage", "dev-hash", - "drop-core/cli", + "drop-core/entities", "drop-core/exchanges/common", "drop-core/exchanges/sender", "drop-core/exchanges/receiver", + "drop-core/common", + "drop-core/cli", + "drop-core/tui", + "drop-core/main", "drop-core/uniffi", ] diff --git a/drop-core/cli/Cargo.toml b/drop-core/cli/Cargo.toml index 268db74b..c6ad674e 100644 --- a/drop-core/cli/Cargo.toml +++ b/drop-core/cli/Cargo.toml @@ -1,37 +1,42 @@ [package] -name = "drop-cli" +name = "arkdrop-cli" version = "1.0.0" edition = "2024" description = "A CLI tool for sending and receiving files" -authors = ["@oluiscabral"] +authors = ["ARK Builders"] license = "MIT" -repository = "https://github.com/Ark-Builders/ark-core" readme = "README.md" -keywords = ["cli", "file-transfer", "p2p"] +repository = "https://github.com/Ark-Builders/ark-core" categories = ["command-line-utilities"] +keywords = ["cli", "file-transfer", "data-transfer", "p2p"] [lib] -name = "drop_cli" bench = false +name = "arkdrop_cli" [[bin]] -name = "drop-cli" -path = "src/main.rs" bench = false +name = "arkdrop-cli" +path = "src/main.rs" [dependencies] +arkdrop-common = { path = "../common" } +arkdropx-sender = { path = "../exchanges/sender" } +arkdropx-receiver = { path = "../exchanges/receiver" } + +toml = "0.8" anyhow = "1.0" -tokio = { version = "1.0", features = ["full"] } -uuid = { version = "1.0", features = ["v4"] } -clap = { version = "4.0", features = ["derive"] } base64 = "0.21" -serde = { version = "1.0", features = ["derive"] } -toml = "0.8" - -dropx-receiver = { path = "../exchanges/receiver" } -dropx-sender = { path = "../exchanges/sender" } +clap = "4.5.47" +uuid = "1.18.1" +tokio = "1.47.1" +qrcode = "0.14.1" +serde = "1.0.219" indicatif = "0.18.0" + [dev-dependencies] tempfile = "3.0" -tokio-test = "0.4" \ No newline at end of file +tokio-test = "0.4" +assert_cmd = "2.1.1" +predicates = "3.1.3" diff --git a/drop-core/cli/README.md b/drop-core/cli/README.md index 719a93ed..2f9cd20d 100644 --- a/drop-core/cli/README.md +++ b/drop-core/cli/README.md @@ -1,6 +1,6 @@ -# Drop CLI +# ARK Drop CLI -A clean, user-friendly CLI tool for sending and receiving files with customizable profiles. +A clean, user-friendly CLI tool for sending and receiving data with customizable profiles. ## Features @@ -15,15 +15,14 @@ A clean, user-friendly CLI tool for sending and receiving files with customizabl ## Installation ```bash -cargo install drop-cli +cargo install arkdrop-cli ``` Or build from source: ```bash -git clone https://github.com/yourusername/drop-cli -cd drop-cli -cargo build --release +git clone https://github.com/ARK-Builders/ark-core +cargo build -p arkdrop-cli --release ``` ## Usage @@ -32,34 +31,34 @@ cargo build --release Basic file sending: ```bash -drop-cli send file1.txt file2.jpg document.pdf +arkdrop-cli send file1.txt file2.jpg document.pdf ``` With custom name: ```bash -drop-cli send --name "Alice" file1.txt file2.jpg +arkdrop-cli send --name "Alice" file1.txt file2.jpg ``` With avatar from file: ```bash -drop-cli send --name "Alice" --avatar avatar.png file1.txt +arkdrop-cli send --name "Alice" --avatar avatar.png file1.txt ``` With base64 avatar: ```bash -drop-cli send --name "Alice" --avatar-b64 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" file1.txt +arkdrop-cli send --name "Alice" --avatar-b64 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" file1.txt ``` ### Receiving Files Basic file receiving: ```bash -drop-cli receive ./downloads "ticket-string" "123" +arkdrop-cli receive ./downloads "ticket-string" "123" ``` With custom name and avatar: ```bash -drop-cli receive --name "Bob" --avatar profile.jpg ./downloads "ticket-string" "123" +arkdrop-cli receive --name "Bob" --avatar profile.jpg ./downloads "ticket-string" "123" ``` ## Command Reference @@ -72,13 +71,13 @@ Send files to another user. - `files`: One or more files to send (required) **Options:** -- `-n, --name `: Your display name (default: "drop-cli-sender") +- `-n, --name `: Your display name (default: "arkdrop-sender") - `-a, --avatar `: Path to avatar image file - `--avatar-b64 `: Base64 encoded avatar image **Example:** ```bash -drop-cli send --name "John" --avatar ./my-avatar.png file1.txt file2.pdf +arkdrop-cli send --name "John" --avatar ./my-avatar.png file1.txt file2.pdf ``` ### `receive` command @@ -91,13 +90,13 @@ Receive files from another user. - `confirmation`: Confirmation code from sender (required) **Options:** -- `-n, --name `: Your display name (default: "drop-cli-receiver") +- `-n, --name `: Your display name (default: "arkdrop-receiver") - `-a, --avatar `: Path to avatar image file - `--avatar-b64 `: Base64 encoded avatar image **Example:** ```bash -drop-cli receive --name "Jane" ./downloads "abc123ticket" "456" +arkdrop-cli receive --name "Jane" ./downloads "abc123ticket" "456" ``` ## Configuration @@ -125,7 +124,7 @@ Avatars can be provided in two ways: ```bash # Send multiple files with custom profile -drop-cli send \ +arkdrop-cli send \ --name "Alice Smith" \ --avatar ./profile.jpg \ document.pdf \ @@ -151,7 +150,7 @@ Output: ```bash # Receive files with custom profile -drop-cli receive \ +arkdrop-cli receive \ --name "Bob Johnson" \ --avatar ./avatar.png \ ./downloads \ diff --git a/drop-core/cli/src/lib.rs b/drop-core/cli/src/lib.rs index 69520ea1..1a5c4aad 100644 --- a/drop-core/cli/src/lib.rs +++ b/drop-core/cli/src/lib.rs @@ -1,4 +1,4 @@ -//! drop-cli library +//! arkdrop_cli library //! //! High-level send/receive helpers and UI for the DropX transfer crates. //! @@ -8,7 +8,7 @@ //! handling. //! - CLI-friendly helpers for configuration and selecting the receive //! directory. -//! - Public async functions to drive sending and receiving from a CLI or app. +//! - Public async functions to drive sending and receiving from another peer. //! //! Concepts //! - Ticket: A short string that identifies an in-progress transfer session. @@ -21,14 +21,14 @@ //! //! Configuration //! - Stores a default receive directory in: -//! $XDG_CONFIG_HOME/drop-cli/config.toml or -//! $HOME/.config/drop-cli/config.toml if XDG_CONFIG_HOME is not set. +//! $XDG_CONFIG_HOME/arkdrop_cli/config.toml or +//! $HOME/.config/arkdrop_cli/config.toml if XDG_CONFIG_HOME is not set. //! //! Examples //! //! Send files //! ```no_run -//! use drop_cli::{run_send_files, Profile}; +//! use arkdrop_cli::{run_send_files, Profile}; //! # async fn demo() -> anyhow::Result<()> { //! let profile = Profile::new("Alice".into(), None); //! run_send_files(vec!["/path/file1.bin".into(), "/path/file2.jpg".into()], profile, true).await?; @@ -38,24 +38,24 @@ //! //! Receive files //! ```no_run -//! use drop_cli::{run_receive_files, Profile}; +//! use arkdrop_cli::{run_receive_files, Profile}; //! # async fn demo() -> anyhow::Result<()> { //! let profile = Profile::default(); -//! // If you want to persist the directory, set save_dir = true +//! // If you want to persist the directory, set save_out = true //! run_receive_files( //! Some("/tmp/downloads".into()), //! "TICKET_STRING".into(), //! "7".into(), //! profile, //! true, // verbose -//! false, // save_dir +//! false, // save_out //! ).await?; //! # Ok(()) //! # } //! ``` use std::{ collections::HashMap, - env, fs, + fs, io::Write, path::PathBuf, str::FromStr, @@ -63,225 +63,38 @@ use std::{ }; use anyhow::{Context, Result, anyhow}; -use base64::{Engine, engine::general_purpose}; -use dropx_receiver::{ +use arkdrop_common::{ + AppConfig, Profile, clear_default_out_dir, get_default_out_dir, + set_default_out_dir, +}; +use arkdropx_receiver::{ ReceiveFilesConnectingEvent, ReceiveFilesFile, ReceiveFilesReceivingEvent, ReceiveFilesRequest, ReceiveFilesSubscriber, ReceiverProfile, receive_files, }; -use dropx_sender::{ - SendFilesConnectingEvent, SendFilesRequest, SendFilesSendingEvent, - SendFilesSubscriber, SenderConfig, SenderFile, SenderFileData, - SenderProfile, send_files, +use arkdropx_sender::{ + SendFilesBubble, SendFilesConnectingEvent, SendFilesRequest, + SendFilesSendingEvent, SendFilesSubscriber, SenderConfig, SenderFile, + SenderFileData, SenderProfile, send_files, }; +use clap::{Arg, ArgMatches, Command}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use serde::{Deserialize, Serialize}; +use qrcode::QrCode; use uuid::Uuid; -/// Configuration for the CLI application. -/// -/// This structure is persisted to TOML and stores user preferences for the CLI -/// usage, such as the default directory to save received files. -/// -/// Storage location: -/// - Linux: $XDG_CONFIG_HOME/drop-cli/config.toml or -/// $HOME/.config/drop-cli/config.toml -/// - macOS: $HOME/Library/Application Support/drop-cli/config.toml -/// - Windows: %APPDATA%\drop-cli\config.toml -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CliConfig { - default_receive_dir: Option, -} - -impl Default for CliConfig { - fn default() -> Self { - Self { - default_receive_dir: None, - } - } -} - -impl CliConfig { - /// Returns the configuration directory path, creating a path under the - /// user's platform-appropriate config directory. - fn config_dir() -> Result { - #[cfg(target_os = "windows")] - { - if let Ok(appdata) = env::var("APPDATA") { - return Ok(PathBuf::from(appdata).join("drop-cli")); - } - // Fallback if APPDATA isn't set (rare) - if let Ok(userprofile) = env::var("USERPROFILE") { - return Ok(PathBuf::from(userprofile) - .join(".config") - .join("drop-cli")); - } - return Err(anyhow!( - "Unable to determine config directory (missing APPDATA/USERPROFILE)" - )); - } - - #[cfg(target_os = "macos")] - { - if let Ok(home) = env::var("HOME") { - return Ok(PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("drop-cli")); - } - return Err(anyhow!( - "Unable to determine config directory (missing HOME)" - )); - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let config_dir = if let Ok(xdg_config_home) = - env::var("XDG_CONFIG_HOME") - { - PathBuf::from(xdg_config_home) - } else if let Ok(home) = env::var("HOME") { - PathBuf::from(home).join(".config") - } else { - return Err(anyhow!( - "Unable to determine config directory (missing XDG_CONFIG_HOME/HOME)" - )); - }; - Ok(config_dir.join("drop-cli")) - } - } - - /// Returns the full config file path. - fn config_file() -> Result { - Ok(Self::config_dir()?.join("config.toml")) - } - - /// Loads the configuration from disk. If the file does not exist, - /// returns a default configuration. - fn load() -> Result { - let config_file = Self::config_file()?; - - if !config_file.exists() { - return Ok(Self::default()); - } - - let config_content = - fs::read_to_string(&config_file).with_context(|| { - format!("Failed to read config file: {}", config_file.display()) - })?; - - let config: CliConfig = toml::from_str(&config_content) - .with_context(|| "Failed to parse config file")?; - - Ok(config) - } - - /// Saves the current configuration to disk, creating the directory if - /// needed. - fn save(&self) -> Result<()> { - let config_dir = Self::config_dir()?; - let config_file = Self::config_file()?; - - if !config_dir.exists() { - fs::create_dir_all(&config_dir).with_context(|| { - format!( - "Failed to create config directory: {}", - config_dir.display() - ) - })?; - } - - let config_content = toml::to_string_pretty(self) - .with_context(|| "Failed to serialize config")?; - - fs::write(&config_file, config_content).with_context(|| { - format!("Failed to write config file: {}", config_file.display()) - })?; - - Ok(()) - } - - /// Updates and persists the default receive directory. - fn set_default_receive_dir(&mut self, dir: String) -> Result<()> { - self.default_receive_dir = Some(dir); - self.save() - } - - /// Returns the saved default receive directory, if any. - fn get_default_receive_dir(&self) -> Option<&String> { - self.default_receive_dir.as_ref() - } -} - -/// Profile for the CLI application. -/// -/// This profile is sent to peers during a transfer to help identify the user. -/// You can set a display name and an optional avatar as a base64-encoded image. -#[derive(Debug, Clone)] -pub struct Profile { - /// Display name shown to peers. - pub name: String, - /// Optional base64-encoded avatar image data. - pub avatar_b64: Option, -} - -impl Default for Profile { - fn default() -> Self { - Self { - name: "drop-cli".to_string(), - avatar_b64: None, - } - } -} - -impl Profile { - /// Create a new profile with a custom name and optional base64 avatar. - /// - /// Example: - /// ```no_run - /// use drop_cli::Profile; - /// let p = Profile::new("Alice".into(), None); - /// ``` - pub fn new(name: String, avatar_b64: Option) -> Self { - Self { name, avatar_b64 } - } - - /// Load avatar from a file path and encode it as base64. - /// - /// Returns an updated Profile on success. - /// - /// Errors: - /// - If the file cannot be read or encoded. - pub fn with_avatar_file(mut self, avatar_path: &str) -> Result { - let avatar_data = fs::read(avatar_path).with_context(|| { - format!("Failed to read avatar file: {}", avatar_path) - })?; - - self.avatar_b64 = Some(general_purpose::STANDARD.encode(&avatar_data)); - Ok(self) - } - - /// Set an avatar from a base64-encoded string and return the updated - /// profile. - pub fn with_avatar_b64(mut self, avatar_b64: String) -> Self { - self.avatar_b64 = Some(avatar_b64); - self - } -} - -/// Enhanced file sender with error handling and progress tracking. +/// File sender with error handling and progress tracking. /// -/// Wraps the lower-level dropx_sender API and provides: +/// Wraps the lower-level arkdropx_sender API and provides: /// - Validation for input paths. /// - Subscription to transfer events with progress bars. /// - Clean cancellation via Ctrl+C. -pub struct FileSender { +struct FileSender { profile: Profile, } impl FileSender { /// Create a new FileSender with the given profile. - pub fn new(profile: Profile) -> Self { + fn new(profile: Profile) -> Self { Self { profile } } @@ -296,7 +109,7 @@ impl FileSender { /// Errors: /// - If any provided path is missing or not a regular file. /// - If the underlying sender fails to initialize or run. - pub async fn send_files( + async fn send_files( &self, file_paths: Vec, verbose: bool, @@ -317,7 +130,7 @@ impl FileSender { let request = SendFilesRequest { files: self.create_sender_files(file_paths)?, - profile: self.get_sender_profile(), + profile: self.create_sender_profile(), config: SenderConfig::default(), }; @@ -329,8 +142,7 @@ impl FileSender { bubble.subscribe(Arc::new(subscriber)); println!("📦 Ready to send files!"); - println!("🎫 Ticket: \"{}\"", bubble.get_ticket()); - println!("🔑 Confirmation: \"{}\"", bubble.get_confirmation()); + print_qr_to_console(&bubble)?; println!("⏳ Waiting for receiver... (Press Ctrl+C to cancel)"); tokio::select! { @@ -347,34 +159,25 @@ impl FileSender { Ok(()) } - /// Converts file paths into SenderFile entries backed by FileData. fn create_sender_files( &self, paths: Vec, ) -> Result> { - let mut files = Vec::new(); + let mut sender_files = Vec::new(); for path in paths { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| { - anyhow!("Invalid file name: {}", path.display()) - })? - .to_string(); - - let data = FileData::new(path)?; - files.push(SenderFile { - name, + let data = FileData::new(path.clone())?; + sender_files.push(SenderFile { + name: path.to_string_lossy().to_string(), data: Arc::new(data), }); } - Ok(files) + Ok(sender_files) } /// Returns a SenderProfile derived from this FileSender's Profile. - fn get_sender_profile(&self) -> SenderProfile { + fn create_sender_profile(&self) -> SenderProfile { SenderProfile { name: self.profile.name.clone(), avatar_b64: self.profile.avatar_b64.clone(), @@ -382,31 +185,61 @@ impl FileSender { } } +fn print_qr_to_console(bubble: &SendFilesBubble) -> Result<()> { + let ticket = bubble.get_ticket(); + let confirmation = bubble.get_confirmation(); + let data = + format!("drop://receive?ticket={ticket}&confirmation={confirmation}"); + + let code = QrCode::new(&data)?; + let image = code + .render::() + .quiet_zone(false) + .module_dimensions(2, 1) + .build(); + + println!("\nQR Code for Transfer:"); + println!("{}", image); + println!("🎫 Ticket: {ticket}"); + println!("🔒 Confirmation: {confirmation}\n"); + + Ok(()) +} + +async fn wait_for_send_completion(bubble: &arkdropx_sender::SendFilesBubble) { + loop { + if bubble.is_finished() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } +} + /// Enhanced file receiver with error handling and progress tracking. /// -/// Wraps the lower-level dropx_receiver API and provides: +/// Wraps the lower-level arkdropx_receiver API and provides: /// - Output directory management (unique subdir per transfer). /// - Subscription to events with per-file progress bars. /// - Clean cancellation via Ctrl+C. -pub struct FileReceiver { +struct FileReceiver { profile: Profile, } impl FileReceiver { /// Create a new FileReceiver with the given profile. - pub fn new(profile: Profile) -> Self { + fn new(profile: Profile) -> Self { Self { profile } } /// Receive files into the provided output directory. /// /// Behavior: - /// - Creates a unique subfolder for the session inside `output_dir`. + /// - Creates a unique subfolder for the session inside `out_dir`. /// - Shows per-file progress bars for known file sizes. /// - Cancels cleanly on Ctrl+C. /// /// Parameters: - /// - output_dir: Parent directory where the unique session folder will be + /// - out_dir: Parent directory where the unique session folder will be /// created. /// - ticket: The ticket provided by the sender. /// - confirmation: The numeric confirmation code. @@ -415,25 +248,25 @@ impl FileReceiver { /// Errors: /// - If directories cannot be created or written. /// - If the underlying receiver fails to initialize or run. - pub async fn receive_files( + async fn receive_files( &self, - output_dir: PathBuf, + out_dir: PathBuf, ticket: String, confirmation: u8, verbose: bool, ) -> Result<()> { // Create output directory if it doesn't exist - if !output_dir.exists() { - fs::create_dir_all(&output_dir).with_context(|| { + if !out_dir.exists() { + fs::create_dir_all(&out_dir).with_context(|| { format!( "Failed to create output directory: {}", - output_dir.display() + out_dir.display() ) })?; } // Create unique subdirectory for this transfer - let receiving_path = output_dir.join(Uuid::new_v4().to_string()); + let receiving_path = out_dir.join(Uuid::new_v4().to_string()); fs::create_dir(&receiving_path).with_context(|| { format!( "Failed to create receiving directory: {}", @@ -488,17 +321,8 @@ impl FileReceiver { } } -async fn wait_for_send_completion(bubble: &dropx_sender::SendFilesBubble) { - loop { - if bubble.is_finished() { - break; - } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } -} - async fn wait_for_receive_completion( - bubble: &dropx_receiver::ReceiveFilesBubble, + bubble: &arkdropx_receiver::ReceiveFilesBubble, ) { loop { if bubble.is_finished() { @@ -541,7 +365,7 @@ impl SendFilesSubscriber for FileSendSubscriber { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("🔍 {message}")); } } @@ -584,9 +408,7 @@ impl SendFilesSubscriber for FileSendSubscriber { } fn notify_connecting(&self, event: SendFilesConnectingEvent) { - let _ = self - .mp - .println("🔗 Connected to receiver:".to_string()); + let _ = self.mp.println("🔗 Connected to receiver:"); let _ = self .mp .println(format!(" 📛 Name: {}", event.receiver.name)); @@ -633,7 +455,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { fn log(&self, message: String) { if self.verbose { - let _ = self.mp.println(format!("🔍 {}", message)); + let _ = self.mp.println(format!("🔍 {message}")); } } @@ -642,7 +464,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { let files = match self.files.read() { Ok(files) => files, Err(e) => { - eprintln!("❌ Error accessing files list: {}", e); + eprintln!("❌ Error accessing files list: {e}"); return; } }; @@ -714,7 +536,6 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } if let Err(e) = file_stream.flush() { eprintln!("❌ Error flushing file {}: {}", file.name, e); - return; } } Err(e) => { @@ -724,9 +545,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } fn notify_connecting(&self, event: ReceiveFilesConnectingEvent) { - let _ = self - .mp - .println("🔗 Connected to sender:".to_string()); + let _ = self.mp.println("🔗 Connected to sender:"); let _ = self .mp .println(format!(" 📛 Name: {}", event.sender.name)); @@ -755,7 +574,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { } } Err(e) => { - eprintln!("❌ Error updating files list: {}", e); + eprintln!("❌ Error updating files list: {e}"); } } } @@ -772,7 +591,7 @@ impl ReceiveFilesSubscriber for FileReceiveSubscriber { /// Notes: /// - Errors are logged and will mark the stream as finished to prevent /// stalling. -struct FileData { +pub struct FileData { is_finished: AtomicBool, path: PathBuf, reader: RwLock>, @@ -785,7 +604,7 @@ impl FileData { /// /// Errors: /// - If the file's metadata cannot be read. - fn new(path: PathBuf) -> Result { + pub fn new(path: PathBuf) -> Result { let metadata = fs::metadata(&path).with_context(|| { format!("Failed to get metadata for file: {}", path.display()) })?; @@ -806,6 +625,11 @@ impl SenderFileData for FileData { self.size } + /// Checks if the data is empty (length is 0). + fn is_empty(&self) -> bool { + self.size == 0 + } + /// Reads a single byte, falling back to EOF (None) at end of file or on /// errors. fn read(&self) -> Option { @@ -963,7 +787,7 @@ impl SenderFileData for FileData { /// /// Example: /// ```no_run -/// use drop_cli::{run_send_files, Profile}; +/// use arkdrop_cli::{run_send_files, Profile}; /// # async fn demo() -> anyhow::Result<()> { /// run_send_files(vec!["/tmp/a.bin".into()], Profile::default(), false).await?; /// # Ok(()) @@ -984,18 +808,18 @@ pub async fn run_send_files( /// Run a receive operation, optionally persisting the chosen output directory. /// -/// If `output_dir` is None, a previously saved default directory is used. +/// If `out_dir` is None, a previously saved default directory is used. /// If no saved default exists, a sensible fallback is chosen: -/// - $HOME/Downloads/Drop if HOME is set +/// - $HOME/Downloads/ARK-Drop if HOME is set /// - or the current directory (.) otherwise /// /// Parameters: -/// - output_dir: Optional parent directory to store the received files. +/// - out_dir: Optional parent directory to store the received files. /// - ticket: Ticket string provided by the sender. /// - confirmation: Numeric confirmation code as a string (parsed to u8). /// - profile: The local user profile to present to the sender. /// - verbose: Enables transport logs and extra diagnostics. -/// - save_dir: If true and `output_dir` is Some, saves it as the default. +/// - save_out: If true and `out_dir` is Some, saves it as the default. /// /// Errors: /// - If the confirmation code is invalid. @@ -1003,7 +827,7 @@ pub async fn run_send_files( /// /// Example: /// ```no_run -/// use drop_cli::{run_receive_files, Profile}; +/// use arkdrop_cli::{run_receive_files, Profile}; /// # async fn demo() -> anyhow::Result<()> { /// run_receive_files( /// Some("/tmp/downloads".into()), @@ -1017,108 +841,339 @@ pub async fn run_send_files( /// # } /// ``` pub async fn run_receive_files( - output_dir: Option, + out_dir: PathBuf, ticket: String, confirmation: String, profile: Profile, verbose: bool, - save_dir: bool, + save_out: bool, ) -> Result<()> { let confirmation_code = u8::from_str(&confirmation).with_context(|| { - format!("Invalid confirmation code: {}", confirmation) + format!("Invalid confirmation code: {confirmation}") })?; - // Determine the output directory - let final_output_dir = match output_dir { - Some(dir) => { - let path = PathBuf::from(&dir); - - // Save this directory as default if requested - if save_dir { - let mut config = CliConfig::load()?; - config - .set_default_receive_dir(dir.clone()) - .with_context( - || "Failed to save default receive directory", - )?; - println!("💾 Saved '{}' as default receive directory", dir); - } - - path - } - None => { - // Try to use saved default directory; otherwise use sensible - // fallback - let config = CliConfig::load()?; - match config.get_default_receive_dir() { - Some(default_dir) => PathBuf::from(default_dir), - None => default_receive_dir_fallback(), - } - } - }; + if save_out { + let mut config = AppConfig::load()?; + config.set_out_dir(out_dir.clone()).with_context( + || "Failed to save default output receive directory", + )?; + println!( + "💾 Saved '{}' as default output receive directory", + out_dir.display() + ); + } let receiver = FileReceiver::new(profile); receiver - .receive_files(final_output_dir, ticket, confirmation_code, verbose) + .receive_files(out_dir, ticket, confirmation_code, verbose) .await } -/// Returns the saved default receive directory path, if any. -/// -/// This reads the TOML config file from the user's config directory. -/// -/// Errors: -/// - If the configuration file cannot be read or parsed. -pub fn get_default_receive_dir() -> Result> { - let config = CliConfig::load()?; - Ok(config.get_default_receive_dir().cloned()) +pub fn build_profile(matches: &ArgMatches) -> Result { + let name = match matches.get_one::("name") { + Some(name) => name.clone(), + None => String::from("Unknown"), + }; + let mut profile = Profile::new(name, None); + + // Handle avatar from file + if let Some(avatar_path) = matches.get_one::("avatar") { + if !avatar_path.exists() { + return Err(anyhow!( + "Avatar file does not exist: {}", + avatar_path.display() + )); + } + profile = profile + .with_avatar_file(&avatar_path.to_string_lossy()) + .with_context(|| "Failed to load avatar file")?; + } + + // Handle avatar from base64 string + if let Some(avatar_b64) = matches.get_one::("avatar-b64") { + profile = profile.with_avatar_b64(avatar_b64.clone()); + } + + Ok(profile) } -/// Returns a suggested default receive directory when no saved default exists: -/// - Linux/macOS: $HOME/Downloads/Drop -/// - Windows: %USERPROFILE%\Downloads\Drop -pub fn suggested_default_receive_dir() -> PathBuf { - default_receive_dir_fallback() +pub async fn run_cli() -> Result<()> { + let cli = build_cli(); + let matches = cli.get_matches(); + run_cli_subcommand(matches).await } -/// Internal: resolve a sensible fallback for receive directory. -fn default_receive_dir_fallback() -> PathBuf { - #[cfg(target_os = "windows")] - { - if let Ok(userprofile) = env::var("USERPROFILE") { - return PathBuf::from(userprofile) - .join("Downloads") - .join("Drop"); +async fn run_cli_subcommand( + matches: ArgMatches, +) -> std::result::Result<(), anyhow::Error> { + match matches.subcommand() { + Some(("send", sub_matches)) => handle_send_command(sub_matches).await, + Some(("receive", sub_matches)) => { + handle_receive_command(sub_matches).await + } + Some(("config", sub_matches)) => { + handle_config_command(sub_matches).await + } + _ => { + eprintln!("❌ Invalid command. Use --help for usage information."); + std::process::exit(1); } - // Last resort: current directory - return std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); } +} - #[cfg(not(target_os = "windows"))] - { - if let Ok(home) = env::var("HOME") { - return PathBuf::from(home).join("Downloads").join("Drop"); - } - // Last resort: current directory - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +pub fn build_cli() -> Command { + Command::new("arkdrop") + .about("ARK Drop tool for sending and receiving files") + .version("1.0.0") + .author("ARK Builders") + .arg( + Arg::new("verbose") + .long("verbose") + .short('v') + .help("Enable verbose logging") + .action(clap::ArgAction::SetTrue) + .global(true) + ) + .subcommand( + Command::new("send") + .about("Send files to another user") + .arg( + Arg::new("files") + .help("Files to send") + .required(true) + .num_args(1..) + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("name") + .long("name") + .short('n') + .help("Your display name") + .default_value("arkdrop-sender") + ) + .arg( + Arg::new("avatar") + .long("avatar") + .short('a') + .help("Path to avatar image file") + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("avatar-b64") + .long("avatar-b64") + .help("Base64 encoded avatar image (alternative to --avatar)") + .conflicts_with("avatar") + ) + ) + .subcommand( + Command::new("receive") + .about("Receive files from another user") + .arg( + Arg::new("ticket") + .help("Transfer ticket") + .required(true) + .index(1) + ) + .arg( + Arg::new("confirmation") + .help("Confirmation code") + .required(true) + .index(2) + ) + .arg( + Arg::new("output") + .help("Output directory for received files (optional if default is set)") + .long("output") + .short('o') + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("save-output") + .long("save-output") + .short('u') + .help("Save the specified output directory as default for future use") + .action(clap::ArgAction::SetTrue) + .requires("output") + ) + .arg( + Arg::new("name") + .long("name") + .short('n') + .help("Your display name") + .default_value("arkdrop-receiver") + ) + .arg( + Arg::new("avatar") + .long("avatar") + .short('a') + .help("Path to avatar image file") + .value_parser(clap::value_parser!(PathBuf)) + ) + .arg( + Arg::new("avatar-b64") + .long("avatar-b64") + .short('b') + .help("Base64 encoded avatar image (alternative to --avatar)") + .conflicts_with("avatar") + ) + ) + .subcommand( + Command::new("config") + .about("Manage ARK Drop CLI configuration") + .subcommand( + Command::new("show") + .about("Show current configuration") + ) + .subcommand( + Command::new("set-output") + .about("Set default receive output directory") + .arg( + Arg::new("output") + .help("Output directory path to set as default") + .required(true) + .value_parser(clap::value_parser!(PathBuf)) + ) + ) + .subcommand( + Command::new("clear-output") + .about("Clear default receive directory") + ) + ) +} + +async fn handle_send_command(matches: &ArgMatches) -> Result<()> { + let files: Vec = matches + .get_many::("files") + .unwrap() + .cloned() + .collect(); + + let verbose: bool = matches.get_flag("verbose"); + + let profile = build_profile(matches)?; + + println!("📤 Preparing to send {} file(s)...", files.len()); + for file in &files { + println!(" 📄 {}", file.display()); + } + + println!("👤 Sender name: {}", profile.name); + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); } + + let file_strings: Vec = files + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + run_send_files(file_strings, profile, verbose).await } -/// Sets the default receive directory and persists it to disk. -/// -/// Errors: -/// - If the configuration cannot be written to the user's config directory. -pub fn set_default_receive_dir(dir: String) -> Result<()> { - let mut config = CliConfig::load()?; - config.set_default_receive_dir(dir) +async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { + let out_dir = matches + .get_one::("output") + .map(|p| PathBuf::from(p)); + let ticket = matches.get_one::("ticket").unwrap(); + let confirmation = matches.get_one::("confirmation").unwrap(); + let verbose = matches.get_flag("verbose"); + let save_output = matches.get_flag("save-output"); + + let profile = build_profile(matches)?; + + println!("📥 Preparing to receive files..."); + + let out_dir = match out_dir { + Some(o) => o, + None => get_default_out_dir(), + }; + + println!("👤 Receiver name: {}", profile.name); + + if profile.avatar_b64.is_some() { + println!("🖼️ Avatar: Set"); + } + + run_receive_files( + out_dir, + ticket.clone(), + confirmation.clone(), + profile, + verbose, + save_output, + ) + .await?; + + Ok(()) } -/// Clears the saved default receive directory. -/// -/// Errors: -/// - If the configuration cannot be written to the user's config directory. -pub fn clear_default_receive_dir() -> Result<()> { - let mut config = CliConfig::load()?; - config.default_receive_dir = None; - config.save() +async fn handle_config_command(matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("show", _)) => { + let out_dir = get_default_out_dir(); + println!( + "📁 Default receive output directory: {}", + out_dir.display() + ); + } + + Some(("set-output", sub_matches)) => { + let out_dir = sub_matches.get_one::("output").unwrap(); + let out_dir_str = out_dir.display(); + + // Validate output exists or can be created + if !out_dir.exists() { + match std::fs::create_dir_all(out_dir) { + Ok(_) => { + println!("📁 Created output directory: {out_dir_str}") + } + Err(e) => { + return Err(anyhow!( + "Failed to create output directory '{}': {}", + out_dir_str, + e + )); + } + } + } + + set_default_out_dir(out_dir.clone())?; + println!( + "✅ Set default receive output directory to: {out_dir_str}" + ); + } + + Some(("clear-output", _)) => { + clear_default_out_dir()?; + println!("✅ Cleared default receive output directory"); + } + _ => { + eprintln!( + "❌ Invalid config command. Use --help for usage information." + ); + std::process::exit(1); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_profile_creation() { + let profile = Profile::new("test-user".to_string(), None); + assert_eq!(profile.name, "test-user"); + assert!(profile.avatar_b64.is_none()); + } + + #[test] + fn test_profile_with_avatar() { + let profile = Profile::new("test-user".to_string(), None) + .with_avatar_b64("dGVzdA==".to_string()); + assert_eq!(profile.name, "test-user"); + assert_eq!(profile.avatar_b64, Some("dGVzdA==".to_string())); + } } diff --git a/drop-core/cli/src/main.rs b/drop-core/cli/src/main.rs index b36e3c3c..ed8378b3 100644 --- a/drop-core/cli/src/main.rs +++ b/drop-core/cli/src/main.rs @@ -1,319 +1,7 @@ -use anyhow::{Context, Result, anyhow}; -use clap::{Arg, ArgMatches, Command}; -use drop_cli::{ - Profile, clear_default_receive_dir, get_default_receive_dir, - run_receive_files, run_send_files, set_default_receive_dir, - suggested_default_receive_dir, -}; -use std::path::PathBuf; +use anyhow::Result; +use arkdrop_cli::run_cli; #[tokio::main] async fn main() -> Result<()> { - let matches = build_cli().get_matches(); - - match matches.subcommand() { - Some(("send", sub_matches)) => handle_send_command(sub_matches).await, - Some(("receive", sub_matches)) => { - handle_receive_command(sub_matches).await - } - Some(("config", sub_matches)) => { - handle_config_command(sub_matches).await - } - _ => { - eprintln!("❌ Invalid command. Use --help for usage information."); - std::process::exit(1); - } - } -} - -fn build_cli() -> Command { - Command::new("drop-cli") - .about("A Drop CLI tool for sending and receiving files") - .version("1.0.0") - .author("oluiscabral@ark-builders.dev") - .arg_required_else_help(true) - .arg( - Arg::new("verbose") - .long("verbose") - .short('v') - .help("Enable verbose logging") - .action(clap::ArgAction::SetTrue) - .global(true) - ) - .subcommand( - Command::new("send") - .about("Send files to another user") - .arg( - Arg::new("files") - .help("Files to send") - .required(true) - .num_args(1..) - .value_parser(clap::value_parser!(PathBuf)) - ) - .arg( - Arg::new("name") - .long("name") - .short('n') - .help("Your display name") - .default_value("drop-cli-sender") - ) - .arg( - Arg::new("avatar") - .long("avatar") - .short('a') - .help("Path to avatar image file") - .value_parser(clap::value_parser!(PathBuf)) - ) - .arg( - Arg::new("avatar-b64") - .long("avatar-b64") - .help("Base64 encoded avatar image (alternative to --avatar)") - .conflicts_with("avatar") - ) - ) - .subcommand( - Command::new("receive") - .about("Receive files from another user") - .arg( - Arg::new("ticket") - .help("Transfer ticket") - .required(true) - .index(1) - ) - .arg( - Arg::new("confirmation") - .help("Confirmation code") - .required(true) - .index(2) - ) - .arg( - Arg::new("output") - .help("Output directory for received files (optional if default is set)") - .long("output") - .short('o') - .value_parser(clap::value_parser!(PathBuf)) - ) - .arg( - Arg::new("save-dir") - .long("save-dir") - .help("Save the specified output directory as default for future use") - .action(clap::ArgAction::SetTrue) - .requires("output") - ) - .arg( - Arg::new("name") - .long("name") - .short('n') - .help("Your display name") - .default_value("drop-cli-receiver") - ) - .arg( - Arg::new("avatar") - .long("avatar") - .short('a') - .help("Path to avatar image file") - .value_parser(clap::value_parser!(PathBuf)) - ) - .arg( - Arg::new("avatar-b64") - .long("avatar-b64") - .help("Base64 encoded avatar image (alternative to --avatar)") - .conflicts_with("avatar") - ) - ) - .subcommand( - Command::new("config") - .about("Manage CLI configuration") - .subcommand( - Command::new("show") - .about("Show current configuration") - ) - .subcommand( - Command::new("set-receive-dir") - .about("Set default receive directory") - .arg( - Arg::new("directory") - .help("Directory path to set as default") - .required(true) - .value_parser(clap::value_parser!(PathBuf)) - ) - ) - .subcommand( - Command::new("clear-receive-dir") - .about("Clear default receive directory") - ) - ) -} - -async fn handle_send_command(matches: &ArgMatches) -> Result<()> { - let files: Vec = matches - .get_many::("files") - .unwrap() - .cloned() - .collect(); - - let verbose: bool = matches.get_flag("verbose"); - - let profile = build_profile(matches)?; - - println!("📤 Preparing to send {} file(s)...", files.len()); - for file in &files { - println!(" 📄 {}", file.display()); - } - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Sender name: {}", name); - } else { - println!("👤 Sender name: {}", profile.name); - } - - if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); - } - - let file_strings: Vec = files - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - - run_send_files(file_strings, profile, verbose).await -} - -async fn handle_receive_command(matches: &ArgMatches) -> Result<()> { - let output_dir = matches - .get_one::("output") - .map(|p| p.to_string_lossy().to_string()); - let ticket = matches.get_one::("ticket").unwrap(); - let confirmation = matches.get_one::("confirmation").unwrap(); - let verbose = matches.get_flag("verbose"); - let save_dir = matches.get_flag("save-dir"); - - let profile = build_profile(matches)?; - - println!("📥 Preparing to receive files..."); - - if let Some(ref dir) = output_dir { - println!("📁 Output directory: {}", dir); - } else if let Some(default_dir) = get_default_receive_dir()? { - println!("📁 Using default directory: {}", default_dir); - } else { - let fallback = suggested_default_receive_dir(); - println!("📁 Using default directory: {}", fallback.display()); - } - - println!("🎫 Ticket: {}", ticket); - println!("🔑 Confirmation: {}", confirmation); - - if let Some(name) = profile.name.strip_prefix("drop-cli-") { - println!("👤 Receiver name: {}", name); - } else { - println!("👤 Receiver name: {}", profile.name); - } - - if profile.avatar_b64.is_some() { - println!("🖼️ Avatar: Set"); - } - - run_receive_files( - output_dir, - ticket.clone(), - confirmation.clone(), - profile, - verbose, - save_dir, - ) - .await -} - -async fn handle_config_command(matches: &ArgMatches) -> Result<()> { - match matches.subcommand() { - Some(("show", _)) => match get_default_receive_dir()? { - Some(dir) => { - println!("📁 Default receive directory: {}", dir); - } - None => { - println!("📁 No default receive directory set"); - } - }, - Some(("set-receive-dir", sub_matches)) => { - let directory = sub_matches - .get_one::("directory") - .unwrap(); - let dir_str = directory.to_string_lossy().to_string(); - - // Validate directory exists or can be created - if !directory.exists() { - match std::fs::create_dir_all(directory) { - Ok(_) => println!("📁 Created directory: {}", dir_str), - Err(e) => { - return Err(anyhow!( - "Failed to create directory '{}': {}", - dir_str, - e - )); - } - } - } - - set_default_receive_dir(dir_str.clone())?; - println!("✅ Set default receive directory to: {}", dir_str); - } - Some(("clear-receive-dir", _)) => { - clear_default_receive_dir()?; - println!("✅ Cleared default receive directory"); - } - _ => { - eprintln!( - "❌ Invalid config command. Use --help for usage information." - ); - std::process::exit(1); - } - } - Ok(()) -} - -fn build_profile(matches: &ArgMatches) -> Result { - let name = matches.get_one::("name").unwrap().clone(); - let mut profile = Profile::new(name, None); - - // Handle avatar from file - if let Some(avatar_path) = matches.get_one::("avatar") { - if !avatar_path.exists() { - return Err(anyhow!( - "Avatar file does not exist: {}", - avatar_path.display() - )); - } - - profile = profile - .with_avatar_file(&avatar_path.to_string_lossy()) - .with_context(|| "Failed to load avatar file")?; - } - - // Handle avatar from base64 string - if let Some(avatar_b64) = matches.get_one::("avatar-b64") { - profile = profile.with_avatar_b64(avatar_b64.clone()); - } - - Ok(profile) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_profile_creation() { - let profile = Profile::new("test-user".to_string(), None); - assert_eq!(profile.name, "test-user"); - assert!(profile.avatar_b64.is_none()); - } - - #[test] - fn test_profile_with_avatar() { - let profile = Profile::new("test-user".to_string(), None) - .with_avatar_b64("dGVzdA==".to_string()); - assert_eq!(profile.name, "test-user"); - assert_eq!(profile.avatar_b64, Some("dGVzdA==".to_string())); - } + run_cli().await } diff --git a/drop-core/cli/tests/integration_tests.rs b/drop-core/cli/tests/integration_tests.rs new file mode 100644 index 00000000..a3155d46 --- /dev/null +++ b/drop-core/cli/tests/integration_tests.rs @@ -0,0 +1,243 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::{PredicateBooleanExt, predicate}; +use tempfile::TempDir; + +/// Test CLI binary exists and is executable +#[test] +fn test_cli_binary_exists() { + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.arg("--version").assert().success(); +} + +/// Test CLI shows help information +#[test] +fn test_cli_help() { + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("ARK Drop tool")) + .stdout(predicate::str::contains("send")) + .stdout(predicate::str::contains("receive")) + .stdout(predicate::str::contains("config")); +} + +/// Test CLI version command +#[test] +fn test_cli_version() { + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("1.0.0")); +} + +/// Test send command shows proper error for missing files +#[test] +fn test_send_missing_file() { + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["send", "nonexistent.txt"]) + .assert() + .failure() + .stderr(predicate::str::contains("File does not exist")); +} + +/// Test receive command shows proper error for missing arguments +#[test] +fn test_receive_missing_args() { + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.arg("receive") + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +/// Test config commands +#[test] +fn test_config_commands() { + // Test config show + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["config", "show"]) + .assert() + .success() + .stdout( + predicate::str::contains("directory") + .or(predicate::str::contains("No default")), + ); + + // Test setting and clearing receive directory + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let temp_path = temp_dir.path().to_str().unwrap(); + + // Set receive directory + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["config", "set-receive-dir", temp_path]) + .assert() + .success(); + + // Verify it was set + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["config", "show"]) + .assert() + .success() + .stdout(predicate::str::contains(temp_path)); + + // Clear receive directory + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["config", "clear-receive-dir"]) + .assert() + .success(); +} + +/// Test send command with valid files (should fail at connection stage) +#[test] +fn test_send_valid_files() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "test content") + .expect("Failed to create test file"); + + // This should fail because there's no receiver, but it should validate the + // file first + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["send", file_path.to_str().unwrap()]) + .assert() + .code(predicate::ne(0)) + .stdout( + predicate::str::contains("Preparing to send") + .or(predicate::str::contains("file(s)")) + .or(predicate::str::contains("ticket")) + .or(predicate::str::contains("confirmation")), + ); +} + +/// Test CLI handles file validation correctly +#[test] +fn test_file_validation() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + + // Create test files + let valid_file = temp_dir.path().join("valid.txt"); + std::fs::write(&valid_file, "valid content") + .expect("Failed to create valid file"); + + // Test with valid file - should pass validation, fail at connection + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args([ + "send", + "--name", + "test-sender", + valid_file.to_str().unwrap(), + ]) + .assert() + .stdout( + predicate::str::contains("Preparing to send") + .or(predicate::str::contains("valid.txt")) + .or(predicate::str::contains("Sender name")), + ); + + // Test with nonexistent file - should fail validation + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args(["send", "--name", "test-sender", "nonexistent.txt"]) + .assert() + .failure() + .stderr( + predicate::str::contains("File does not exist") + .or(predicate::str::contains("not found")), + ); +} + +/// Test receive command parameter validation +#[test] +fn test_receive_parameters() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let output_path = temp_dir.path().to_str().unwrap(); + + // Test with all required parameters - should attempt connection and fail + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args([ + "receive", + "test-ticket", + "123", + "--output", + output_path, + "--name", + "test-receiver", + ]) + .assert() + .stdout( + predicate::str::contains("Preparing to receive") + .or(predicate::str::contains("test-ticket")) + .or(predicate::str::contains("Receiver name")) + .or(predicate::str::contains("Output directory")), + ); +} + +/// Test avatar handling +#[test] +fn test_avatar_options() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let avatar_file = temp_dir.path().join("avatar.png"); + let test_file = temp_dir.path().join("test.txt"); + + // Create dummy files + std::fs::write(&avatar_file, b"fake image data") + .expect("Failed to create avatar file"); + std::fs::write(&test_file, "test content") + .expect("Failed to create test file"); + + // Test with avatar file + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args([ + "send", + "--name", + "avatar-sender", + "--avatar", + avatar_file.to_str().unwrap(), + test_file.to_str().unwrap(), + ]) + .assert() + .stdout( + predicate::str::contains("Avatar: Set") + .or(predicate::str::contains("avatar-sender")), + ); + + // Test with base64 avatar + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args([ + "send", + "--name", + "b64-sender", + "--avatar-b64", + "dGVzdA==", + test_file.to_str().unwrap(), + ]) + .assert() + .stdout( + predicate::str::contains("Avatar: Set") + .or(predicate::str::contains("b64-sender")), + ); +} + +/// Test verbose flag +#[test] +fn test_verbose_flag() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_file = temp_dir.path().join("test.txt"); + std::fs::write(&test_file, "test content") + .expect("Failed to create test file"); + + let mut cmd = cargo_bin_cmd!("arkdrop-cli"); + cmd.args([ + "send", + "--verbose", + "--name", + "verbose-sender", + test_file.to_str().unwrap(), + ]) + .assert() + .stdout( + predicate::str::contains("verbose-sender") + .or(predicate::str::contains("Preparing to send")), + ); +} diff --git a/drop-core/cli/tests/network_simulation.rs b/drop-core/cli/tests/network_simulation.rs new file mode 100644 index 00000000..39cdaea7 --- /dev/null +++ b/drop-core/cli/tests/network_simulation.rs @@ -0,0 +1,375 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::Duration, +}; +use tempfile::TempDir; + +/// Test helper to simulate network conditions +pub struct NetworkSimulator { + interface: String, + original_state: Option, +} + +impl NetworkSimulator { + pub fn new(interface: &str) -> Self { + Self { + interface: interface.to_string(), + original_state: None, + } + } + + /// Add packet loss to the network interface + pub fn add_packet_loss( + &mut self, + percentage: f32, + ) -> Result<(), Box> { + self.save_state()?; + + Command::new("sudo") + .args([ + "tc", + "qdisc", + "add", + "dev", + &self.interface, + "root", + "netem", + "loss", + &format!("{percentage}%"), + ]) + .output()?; + + Ok(()) + } + + /// Add latency to the network interface + pub fn add_latency( + &mut self, + delay_ms: u32, + ) -> Result<(), Box> { + self.save_state()?; + + Command::new("sudo") + .args([ + "tc", + "qdisc", + "add", + "dev", + &self.interface, + "root", + "netem", + "delay", + &format!("{delay_ms}ms"), + ]) + .output()?; + + Ok(()) + } + + /// Add bandwidth limitation + pub fn limit_bandwidth( + &mut self, + rate_kbps: u32, + ) -> Result<(), Box> { + self.save_state()?; + + Command::new("sudo") + .args([ + "tc", + "qdisc", + "add", + "dev", + &self.interface, + "root", + "tbf", + "rate", + &format!("{rate_kbps}kbit"), + "burst", + "32kbit", + "latency", + "400ms", + ]) + .output()?; + + Ok(()) + } + + /// Add jitter (variable latency) + pub fn add_jitter( + &mut self, + base_delay_ms: u32, + jitter_ms: u32, + ) -> Result<(), Box> { + self.save_state()?; + + Command::new("sudo") + .args([ + "tc", + "qdisc", + "add", + "dev", + &self.interface, + "root", + "netem", + "delay", + &format!("{base_delay_ms}ms"), + &format!("{jitter_ms}ms"), + "distribution", + "normal", + ]) + .output()?; + + Ok(()) + } + + /// Save current network state + fn save_state(&mut self) -> Result<(), Box> { + let output = Command::new("sudo") + .args(["tc", "qdisc", "show", "dev", &self.interface]) + .output()?; + + self.original_state = + Some(String::from_utf8_lossy(&output.stdout).to_string()); + Ok(()) + } + + /// Reset network to original state + pub fn reset(&self) -> Result<(), Box> { + Command::new("sudo") + .args(["tc", "qdisc", "del", "dev", &self.interface, "root"]) + .output() + .ok(); // Ignore errors as there might not be any qdisc to delete + + Ok(()) + } +} + +impl Drop for NetworkSimulator { + fn drop(&mut self) { + // Always try to reset network state on cleanup + let _ = self.reset(); + } +} + +/// Helper to run arkdrop-cli commands +pub struct ARKDropRunner { + binary_path: PathBuf, +} + +impl ARKDropRunner { + pub fn new() -> Result> { + // Build the CLI in release mode if not already built + Command::new("cargo") + .args(["build", "--release"]) + .current_dir(".") + .output()?; + + Ok(Self { + binary_path: PathBuf::from("target/release/arkdrop-cli"), + }) + } + + /// Start a receiver and return the process handle and connection info + pub fn start_receiver( + &self, + name: &str, + out_dir: &Path, + ) -> Result<(std::process::Child, String, String), Box> + { + let child = Command::new(&self.binary_path) + .args([ + "receive", + "--name", + name, + "--dir", + out_dir.to_str().unwrap(), + "--verbose", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + // Wait for receiver to start and extract connection info + thread::sleep(Duration::from_secs(2)); + + // In a real implementation, we'd parse stdout to get ticket and code + // For testing, we'll use placeholder values + let ticket = "test-ticket-123"; + let code = "test-code-456"; + + Ok((child, ticket.to_string(), code.to_string())) + } + + /// Send files + pub fn send_files( + &self, + name: &str, + files: Vec, + ticket: &str, + code: &str, + ) -> Result<(), Box> { + let mut args = vec![ + "send".to_string(), + "--name".to_string(), + name.to_string(), + "--verbose".to_string(), + ]; + + for file in files { + args.push(file.to_str().unwrap().to_string()); + } + + args.push(ticket.to_string()); + args.push(code.to_string()); + + let output = Command::new(&self.binary_path) + .args(&args) + .output()?; + + if !output.status.success() { + return Err(format!( + "Send command failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] // Requires sudo permissions + fn test_packet_loss_simulation() { + let mut simulator = NetworkSimulator::new("lo"); + simulator + .add_packet_loss(5.0) + .expect("Failed to add packet loss"); + + // Run transfer test here + + simulator + .reset() + .expect("Failed to reset network"); + } + + #[test] + #[ignore] // Requires sudo permissions + fn test_latency_simulation() { + let mut simulator = NetworkSimulator::new("lo"); + simulator + .add_latency(100) + .expect("Failed to add latency"); + + // Run transfer test here + + simulator + .reset() + .expect("Failed to reset network"); + } + + #[test] + fn test_basic_file_transfer() { + let runner = ARKDropRunner::new().expect("Failed to create runner"); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let out_dir = temp_dir.path().join("output"); + fs::create_dir(&out_dir).expect("Failed to create output dir"); + + // Create test file + let test_file = temp_dir.path().join("test.txt"); + fs::write(&test_file, "Test content") + .expect("Failed to write test file"); + + // Start receiver + let (mut receiver, ticket, code) = runner + .start_receiver("Test Receiver", &out_dir) + .expect("Failed to start receiver"); + + // Send file + runner + .send_files("Test Sender", vec![test_file.clone()], &ticket, &code) + .expect("Failed to send files"); + + // Wait for transfer to complete + thread::sleep(Duration::from_secs(2)); + + // Verify file was received + let received_file = out_dir.join("test.txt"); + assert!(received_file.exists(), "File was not received"); + + let content = fs::read_to_string(&received_file) + .expect("Failed to read received file"); + assert_eq!(content, "Test content", "File content mismatch"); + + // Clean up + receiver.kill().ok(); + } + + #[test] + fn test_multiple_file_transfer() { + let runner = ARKDropRunner::new().expect("Failed to create runner"); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let out_dir = temp_dir.path().join("output"); + fs::create_dir(&out_dir).expect("Failed to create output dir"); + + // Create multiple test files + let mut test_files = Vec::new(); + for i in 0..3 { + let file = temp_dir.path().join(format!("test{i}.txt")); + fs::write(&file, format!("Content {i}")) + .expect("Failed to write test file"); + test_files.push(file); + } + + // Start receiver + let (mut receiver, ticket, code) = runner + .start_receiver("Multi Receiver", &out_dir) + .expect("Failed to start receiver"); + + // Send files + runner + .send_files("Multi Sender", test_files.clone(), &ticket, &code) + .expect("Failed to send files"); + + // Wait for transfer to complete + thread::sleep(Duration::from_secs(3)); + + // Verify all files were received + for i in 0..3 { + let received_file = out_dir.join(format!("test{i}.txt")); + assert!(received_file.exists(), "File {i} was not received"); + + let content = fs::read_to_string(&received_file) + .expect("Failed to read received file"); + assert_eq!( + content, + format!("Content {i}"), + "File {} content mismatch", + i + ); + } + + // Clean up + receiver.kill().ok(); + } + + #[test] + #[ignore] // Requires network setup + fn test_nat_traversal() { + // This test would require setting up network namespaces + // and is more suitable for the GitHub Actions workflow + + // The actual implementation would: + // 1. Create network namespaces + // 2. Setup NAT rules + // 3. Run sender and receiver in different namespaces + // 4. Verify successful transfer through NAT + } +} diff --git a/drop-core/common/Cargo.toml b/drop-core/common/Cargo.toml new file mode 100644 index 00000000..1c43e7b5 --- /dev/null +++ b/drop-core/common/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "arkdrop-common" +version = "0.1.0" +edition = "2024" + +[lib] +bench = false +name = "arkdrop_common" + +[dependencies] +arkdropx-sender = { path = "../exchanges/sender" } + +image = "0.25" +toml = "0.9.5" +anyhow = "1.0.99" +serde = "1.0.219" +base64 = "0.22.1" diff --git a/drop-core/common/src/lib.rs b/drop-core/common/src/lib.rs new file mode 100644 index 00000000..c4d6e99f --- /dev/null +++ b/drop-core/common/src/lib.rs @@ -0,0 +1,530 @@ +//! arkdrop_common library +//! ``` +use std::{ + env, + fs::{self}, + io::Cursor, + path::PathBuf, + sync::{RwLock, atomic::AtomicBool}, +}; + +use anyhow::{Context, Result, anyhow}; +use arkdropx_sender::SenderFileData; +use base64::{Engine, engine::general_purpose}; +use image::ImageFormat; +use serde::{Deserialize, Serialize}; + +/// Configuration for the application. +/// +/// This structure is persisted to TOML and stores user preferences for the app +/// usage, such as the default directory to save received files. +/// +/// Storage location: +/// - Linux: $XDG_CONFIG_HOME/arkdrop_common/config.toml or +/// $HOME/.config/arkdrop_common/config.toml +/// - macOS: $HOME/Library/Application Support/arkdrop_common/config.toml +/// - Windows: %APPDATA%\arkdrop_common\config.toml +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct AppConfig { + pub out_dir: Option, + pub avatar_name: Option, + pub avatar_file: Option, +} + +impl AppConfig { + /// Returns the configuration directory path, creating a path under the + /// user's platform-appropriate config directory. + fn config_dir() -> Result { + let default_path = "ARK-Drop"; + + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = env::var("APPDATA") { + return Ok(PathBuf::from(appdata).join(default_path)); + } + // Fallback if APPDATA isn't set (rare) + if let Ok(userprofile) = env::var("USERPROFILE") { + return Ok(PathBuf::from(userprofile) + .join(".config") + .join("ARK-Drop")); + } + return Err(anyhow!( + "Unable to determine config directory (missing APPDATA/USERPROFILE)" + )); + } + + #[cfg(target_os = "macos")] + { + if let Ok(home) = env::var("HOME") { + return Ok(PathBuf::from(home) + .join("Library") + .join("Application Support") + .join(default_path)); + } + return Err(anyhow!( + "Unable to determine config directory (missing HOME)" + )); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let config_dir = if let Ok(xdg_config_home) = + env::var("XDG_CONFIG_HOME") + { + PathBuf::from(xdg_config_home) + } else if let Ok(home) = env::var("HOME") { + PathBuf::from(home).join(".config") + } else { + return Err(anyhow!( + "Unable to determine config directory (missing XDG_CONFIG_HOME/HOME)" + )); + }; + Ok(config_dir.join(default_path)) + } + } + + /// Returns the full config file path. + fn config_file() -> Result { + Ok(Self::config_dir()?.join("config.toml")) + } + + /// Loads the configuration from disk. If the file does not exist, + /// returns a default configuration. + pub fn load() -> Result { + let config_file = Self::config_file()?; + + if !config_file.exists() { + return Ok(Self::default()); + } + + let config_content = + fs::read_to_string(&config_file).with_context(|| { + format!("Failed to read config file: {}", config_file.display()) + })?; + + let config: AppConfig = toml::from_str(&config_content) + .with_context(|| "Failed to parse config file")?; + + Ok(config) + } + + /// Saves the current configuration to disk, creating the directory if + /// needed. + pub fn save(&self) -> Result<()> { + let config_dir = Self::config_dir()?; + let config_file = Self::config_file()?; + + if !config_dir.exists() { + fs::create_dir_all(&config_dir).with_context(|| { + format!( + "Failed to create config directory: {}", + config_dir.display() + ) + })?; + } + + let config_content = toml::to_string_pretty(self) + .with_context(|| "Failed to serialize config")?; + + fs::write(&config_file, config_content).with_context(|| { + format!("Failed to write config file: {}", config_file.display()) + })?; + + Ok(()) + } + + pub fn set_avatar_name(&mut self, name: String) { + self.avatar_name.replace(name); + } + + pub fn get_avatar_name(&self) -> String { + self.avatar_name + .clone() + .unwrap_or("unknown".to_string()) + } + + pub fn get_avatar_base64(&self) -> Option { + if let Some(path) = &self.avatar_file { + return transform_to_base64(path).ok(); + } + + None + } + + pub fn set_avatar_file(&mut self, file: PathBuf) { + self.avatar_file.replace(file); + } + + /// Updates and persists the default receive directory. + pub fn set_out_dir(&mut self, dir: PathBuf) -> Result<()> { + self.out_dir = Some(dir); + self.save() + } + + /// Returns the saved default receive directory, if any. + pub fn get_out_dir(&self) -> PathBuf { + match self.out_dir.clone() { + Some(dir) => dir.clone(), + None => suggested_default_out_dir(), + } + } +} + +pub fn transform_to_base64(path: &PathBuf) -> Result { + let img = image::open(path)?; + + let resized = img.resize(64, 64, image::imageops::FilterType::Lanczos3); + + // Convert to JPEG format for smaller size + let mut buffer = Vec::new(); + let mut cursor = Cursor::new(&mut buffer); + + resized.write_to(&mut cursor, ImageFormat::Jpeg)?; + + // Encode to base64 + let base64_string = general_purpose::STANDARD.encode(&buffer); + + Ok(format!("data:image/jpeg;base64,{}", base64_string)) +} + +/// Profile for the application. +/// +/// This profile is sent to peers during a transfer to help identify the user. +/// You can set a display name and an optional avatar as a base64-encoded image. +#[derive(Debug, Clone)] +pub struct Profile { + /// Display name shown to peers. + pub name: String, + /// Optional base64-encoded avatar image data. + pub avatar_b64: Option, +} + +impl Default for Profile { + fn default() -> Self { + Self { + name: "arkdrop".to_string(), + avatar_b64: None, + } + } +} + +impl Profile { + /// Create a new profile with a custom name and optional base64 avatar. + /// + /// Example: + /// ```no_run + /// use arkdrop_common::Profile; + /// let p = Profile::new("Alice".into(), None); + /// ``` + pub fn new(name: String, avatar_b64: Option) -> Self { + Self { name, avatar_b64 } + } + + /// Load avatar from a file path and encode it as base64. + /// + /// Returns an updated Profile on success. + /// + /// Errors: + /// - If the file cannot be read or encoded. + pub fn with_avatar_file(mut self, avatar_path: &str) -> Result { + let avatar_data = fs::read(avatar_path).with_context(|| { + format!("Failed to read avatar file: {avatar_path}") + })?; + + self.avatar_b64 = Some(general_purpose::STANDARD.encode(&avatar_data)); + Ok(self) + } + + /// Set an avatar from a base64-encoded string and return the updated + /// profile. + pub fn with_avatar_b64(mut self, avatar_b64: String) -> Self { + self.avatar_b64 = Some(avatar_b64); + self + } +} + +/// In-memory, seek-based file data source for the sender. +/// +/// This implementation: +/// - Supports both single-byte reads (`read`) and ranged chunk reads +/// (`read_chunk`). +/// - Uses atomic counters to coordinate chunked read offsets safely. +/// - Reports its total length through `len`. +/// +/// Notes: +/// - Errors are logged and will mark the stream as finished to prevent +/// stalling. +pub struct FileData { + is_finished: AtomicBool, + path: PathBuf, + reader: RwLock>, + size: u64, + bytes_read: std::sync::atomic::AtomicU64, +} + +impl FileData { + /// Create a new FileData for the given path, capturing size metadata. + /// + /// Errors: + /// - If the file's metadata cannot be read. + pub fn new(path: PathBuf) -> Result { + let metadata = fs::metadata(&path).with_context(|| { + format!("Failed to get metadata for file: {}", path.display()) + })?; + + Ok(Self { + is_finished: AtomicBool::new(false), + path, + reader: RwLock::new(None), + size: metadata.len(), + bytes_read: std::sync::atomic::AtomicU64::new(0), + }) + } +} + +impl SenderFileData for FileData { + /// Returns the total file size in bytes. + fn len(&self) -> u64 { + self.size + } + + /// Checks if the data is empty (length is 0). + fn is_empty(&self) -> bool { + self.size == 0 + } + + /// Reads a single byte, falling back to EOF (None) at end of file or on + /// errors. + fn read(&self) -> Option { + use std::io::Read; + + if self + .is_finished + .load(std::sync::atomic::Ordering::Relaxed) + { + return None; + } + + if self.reader.read().unwrap().is_none() { + match std::fs::File::open(&self.path) { + Ok(file) => { + *self.reader.write().unwrap() = Some(file); + } + Err(e) => { + eprintln!( + "❌ Error opening file {}: {}", + self.path.display(), + e + ); + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + return None; + } + } + } + + // Read next byte + let mut reader = self.reader.write().unwrap(); + if let Some(file) = reader.as_mut() { + let mut buffer = [0u8; 1]; + match file.read(&mut buffer) { + Ok(bytes_read) => { + if bytes_read == 0 { + *reader = None; + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + None + } else { + Some(buffer[0]) + } + } + Err(e) => { + eprintln!( + "❌ Error reading from file {}: {}", + self.path.display(), + e + ); + *reader = None; + self.is_finished + .store(true, std::sync::atomic::Ordering::Relaxed); + None + } + } + } else { + None + } + } + + /// Reads up to `size` bytes as a contiguous chunk starting from the next + /// claimed position. Returns an empty Vec when the file is fully consumed + /// or on errors. + fn read_chunk(&self, size: u64) -> Vec { + use std::{ + io::{Read, Seek, SeekFrom}, + sync::atomic::Ordering, + }; + + if self.is_finished.load(Ordering::Acquire) { + return Vec::new(); + } + + // Atomically claim the next chunk position + let current_position = + self.bytes_read.fetch_add(size, Ordering::AcqRel); + + // Check if we've already passed the end of the file + if current_position >= self.size { + // Reset the bytes_read counter and mark as finished + self.bytes_read + .store(self.size, Ordering::Release); + self.is_finished.store(true, Ordering::Release); + return Vec::new(); + } + + // Calculate how much to actually read (don't exceed file size) + let remaining = self.size - current_position; + let to_read = std::cmp::min(size, remaining) as usize; + + // Open a new file handle for this read operation + let mut file = match std::fs::File::open(&self.path) { + Ok(file) => file, + Err(e) => { + eprintln!( + "❌ Error opening file {}: {}", + self.path.display(), + e + ); + self.is_finished.store(true, Ordering::Release); + return Vec::new(); + } + }; + + // Seek to the claimed position + if let Err(e) = file.seek(SeekFrom::Start(current_position)) { + eprintln!( + "❌ Error seeking to position {} in file {}: {}", + current_position, + self.path.display(), + e + ); + self.is_finished.store(true, Ordering::Release); + return Vec::new(); + } + + // Read the chunk + let mut buffer = vec![0u8; to_read]; + match file.read_exact(&mut buffer) { + Ok(()) => { + // Check if we've finished reading the entire file + if current_position + to_read as u64 >= self.size { + self.is_finished.store(true, Ordering::Release); + } + + buffer + } + Err(e) => { + eprintln!( + "❌ Error reading chunk from file {}: {}", + self.path.display(), + e + ); + self.is_finished.store(true, Ordering::Release); + Vec::new() + } + } + } +} + +/// Returns the saved default receive directory path, if any, otherwise returns +/// fallback. +/// +/// This reads the TOML config file from the user's config directory. +/// +/// Errors: +/// - If the configuration file cannot be read or parsed. +pub fn get_default_out_dir() -> PathBuf { + if let Ok(config) = AppConfig::load() { + return config.get_out_dir(); + } + suggested_default_out_dir() +} + +/// Returns a suggested default receive directory when no saved default exists: +/// - Linux/macOS: $HOME/Downloads/ARK-Drop +/// - Windows: %USERPROFILE%\Downloads\Drop +fn suggested_default_out_dir() -> PathBuf { + default_out_dir_fallback() +} + +/// Internal: resolve a sensible fallback for receive directory. +fn default_out_dir_fallback() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Ok(userprofile) = env::var("USERPROFILE") { + return PathBuf::from(userprofile) + .join("Downloads") + .join("Drop"); + } + // Last resort: current directory + return std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + } + + #[cfg(not(target_os = "windows"))] + { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join("Downloads").join("Drop"); + } + // Last resort: current directory + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } +} + +/// Sets the default receive directory and persists it to disk. +/// +/// Errors: +/// - If the configuration cannot be written to the user's config directory. +pub fn set_default_out_dir(dir: PathBuf) -> Result<()> { + let mut config = AppConfig::load()?; + config.set_out_dir(dir) +} + +/// Clears the saved default receive directory. +/// +/// Errors: +/// - If the configuration cannot be written to the user's config directory. +pub fn clear_default_out_dir() -> Result<()> { + let mut config = AppConfig::load()?; + config.out_dir = None; + config.save() +} + +#[derive(Clone)] +pub struct TransferFile { + pub id: String, + pub name: String, + pub path: PathBuf, + pub len: u64, + pub expected_len: u64, +} +impl TransferFile { + pub fn new( + id: String, + name: String, + path: PathBuf, + expected_len: u64, + ) -> Self { + Self { + id, + name, + path, + len: 0, + expected_len, + } + } + + pub fn get_pct(&self) -> f64 { + let raw_pct = self.len / self.expected_len; + let pct: u32 = raw_pct.try_into().unwrap_or(0); + pct.try_into().unwrap_or(0.0) + } +} diff --git a/drop-core/entities/Cargo.toml b/drop-core/entities/Cargo.toml index 432ab413..400ad331 100644 --- a/drop-core/entities/Cargo.toml +++ b/drop-core/entities/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "drop-entities" +name = "arkdrop-entities" version = "1.0.0" edition = "2024" [lib] -name = "drop_entities" +name = "arkdrop_entities" bench = false [dependencies] diff --git a/drop-core/entities/src/data.rs b/drop-core/entities/src/data.rs index 4af6480c..1cbf169f 100644 --- a/drop-core/entities/src/data.rs +++ b/drop-core/entities/src/data.rs @@ -35,6 +35,13 @@ pub trait Data: Send + Sync { /// the known total length at creation time. fn len(&self) -> u64; + /// Checks if the data is empty (length is 0). + /// + /// Default implementation returns `true` if `len() == 0`. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Reads the next byte from the current position. /// /// Returns: diff --git a/drop-core/entities/src/lib.rs b/drop-core/entities/src/lib.rs index 627a3bc6..32a21c1d 100644 --- a/drop-core/entities/src/lib.rs +++ b/drop-core/entities/src/lib.rs @@ -12,7 +12,7 @@ //! use std::sync::{Arc, Mutex}; //! //! // Bring items into scope when used from your crate: -//! // use drop_entities::{Data, File, Profile}; +//! // use arkdrop_entities::{Data, File, Profile}; //! //! // A simple in-memory Data implementation with a protected cursor. //! struct InMemoryData { @@ -25,7 +25,7 @@ //! } //! } //! // Implement the trait from this crate for the type above. -//! impl drop_entities::Data for InMemoryData { +//! impl arkdrop_entities::Data for InMemoryData { //! fn len(&self) -> u64 { self.buf.len() as u64 } //! fn read(&self) -> Option { //! let mut p = self.pos.lock().unwrap(); @@ -46,7 +46,7 @@ //! //! // Construct a File backed by the in-memory data. //! let data = Arc::new(InMemoryData::new(b"hello")); -//! let file = drop_entities::File { +//! let file = arkdrop_entities::File { //! id: "file-1".into(), //! name: "greeting.txt".into(), //! data: data.clone(), @@ -58,7 +58,7 @@ //! assert_eq!(file.data.read_chunk(2), b"he".to_vec()); //! //! // A simple profile -//! let profile = drop_entities::Profile { id: "42".into(), name: "Ada".into(), avatar_b64: None }; +//! let profile = arkdrop_entities::Profile { id: "42".into(), name: "Ada".into(), avatar_b64: None }; //! assert_eq!(profile.name, "Ada"); //! ``` diff --git a/drop-core/exchanges/common/Cargo.toml b/drop-core/exchanges/common/Cargo.toml index 03550451..b6921922 100644 --- a/drop-core/exchanges/common/Cargo.toml +++ b/drop-core/exchanges/common/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "dropx-common" +name = "arkdropx-common" version = "1.0.0" edition = "2024" [lib] -name = "dropx_common" +name = "arkdropx_common" bench = false [dependencies] diff --git a/drop-core/exchanges/common/src/handshake.rs b/drop-core/exchanges/common/src/handshake.rs index 5051b1c1..cdeae803 100644 --- a/drop-core/exchanges/common/src/handshake.rs +++ b/drop-core/exchanges/common/src/handshake.rs @@ -124,7 +124,7 @@ impl NegotiatedConfig { /// /// Example: /// ``` - /// use dropx_common::handshake::{HandshakeConfig, NegotiatedConfig}; + /// use arkdropx_common::handshake::{HandshakeConfig, NegotiatedConfig}; /// /// let sender = HandshakeConfig { chunk_size: 64 * 1024, parallel_streams: 4 }; /// let receiver = HandshakeConfig { chunk_size: 32 * 1024, parallel_streams: 8 }; diff --git a/drop-core/exchanges/receiver/Cargo.toml b/drop-core/exchanges/receiver/Cargo.toml index 685a86b3..393813b1 100644 --- a/drop-core/exchanges/receiver/Cargo.toml +++ b/drop-core/exchanges/receiver/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "dropx-receiver" +name = "arkdropx-receiver" version = "1.0.0" edition = "2024" [lib] -name = "dropx_receiver" +name = "arkdropx_receiver" bench = false [dependencies] -drop-entities = { path = "../../entities" } -dropx-common = { path = "../common" } +arkdrop-entities = { path = "../../entities" } +arkdropx-common = { path = "../common" } uuid = "1.16.0" iroh = "0.91.1" diff --git a/drop-core/exchanges/receiver/src/lib.rs b/drop-core/exchanges/receiver/src/lib.rs index 1c31f62b..7e8fe860 100644 --- a/drop-core/exchanges/receiver/src/lib.rs +++ b/drop-core/exchanges/receiver/src/lib.rs @@ -18,11 +18,6 @@ mod receive_files; -use std::{ - io::{Bytes, Read}, - sync::{RwLock, atomic::AtomicBool}, -}; - pub use receive_files::*; /// Identity and presentation for the receiving peer. @@ -92,93 +87,3 @@ impl ReceiverConfig { } } } - -/// Metadata and data carrier representing a file being received. -/// -/// Note: Depending on your flow, you may receive file data via events -/// (see `receive_files` module) rather than pulling bytes directly from this -/// structure. -#[derive(Debug)] -pub struct ReceiverFile { - /// Unique, sender-provided file identifier. - pub id: String, - /// Human-readable file name (as provided by sender). - pub name: String, - /// Backing data accessor for byte-wise reads. - pub data: ReceiverFileData, -} - -/// Backing data abstraction for a locally stored file used by the receiver. -/// -/// This type supports: -/// - Lazy initialization of a byte iterator over the file. -/// - Byte-wise `read()` until EOF, returning `None` when complete. -/// - A simple `is_finished` flag to short-circuit further reads after EOF. -/// -/// Caveats: -/// - `len()` currently counts bytes by iterating the file; this is O(n) and -/// re-reads the file. Prefer using file metadata for length if available. -/// - `read()` is not optimized for high-throughput stream reads; it is intended -/// for simple scenarios and examples. Use buffered I/O where performance -/// matters. -#[derive(Debug)] -pub struct ReceiverFileData { - is_finished: AtomicBool, - path: std::path::PathBuf, - reader: RwLock>>, -} -impl ReceiverFileData { - /// Create a new `ReceiverFileData` from a filesystem path. - pub fn new(path: std::path::PathBuf) -> Self { - return Self { - is_finished: AtomicBool::new(false), - path, - reader: RwLock::new(None), - }; - } - - /// Return the file length in bytes by counting the iterator. - /// - /// Note: This reads the entire file to count bytes and is therefore O(n). - /// If possible, prefer using `std::fs::metadata(&path)?.len()` in your own - /// code where you have direct access to the path. - pub fn len(&self) -> u64 { - let file = std::fs::File::open(&self.path).unwrap(); - return file.bytes().count() as u64; - } - - /// Read the next byte from the file, returning `None` at EOF or after - /// the stream has been marked finished. - /// - /// This initializes an internal iterator on first use and cleans it up - /// when EOF is reached. Subsequent calls after completion return `None`. - pub fn read(&self) -> Option { - if self - .is_finished - .load(std::sync::atomic::Ordering::Relaxed) - { - return None; - } - if self.reader.read().unwrap().is_none() { - let file = std::fs::File::open(&self.path).unwrap(); - self.reader.write().unwrap().replace(file.bytes()); - } - let next = self - .reader - .write() - .unwrap() - .as_mut() - .unwrap() - .next(); - if next.is_some() { - let read_result = next.unwrap(); - if read_result.is_ok() { - return Some(read_result.unwrap()); - } - } - self.reader.write().unwrap().as_mut().take(); - self.is_finished - .store(true, std::sync::atomic::Ordering::Relaxed); - return None; - } -} diff --git a/drop-core/exchanges/receiver/src/receive_files.rs b/drop-core/exchanges/receiver/src/receive_files.rs index 8c31dea0..95f18262 100644 --- a/drop-core/exchanges/receiver/src/receive_files.rs +++ b/drop-core/exchanges/receiver/src/receive_files.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use drop_entities::Profile; -use dropx_common::{ +use arkdrop_entities::Profile; +use arkdropx_common::{ handshake::{ HandshakeConfig, HandshakeProfile, NegotiatedConfig, ReceiverHandshake, SenderHandshake, @@ -104,8 +104,7 @@ impl ReceiveFilesBubble { .load(std::sync::atomic::Ordering::Acquire); if is_consumed { - self.log(format!("start: Cannot start transfer, it has already started - consumed: {}", - is_consumed)); + self.log(format!("start: Cannot start transfer, it has already started - consumed: {is_consumed}")); return Err(anyhow::Error::msg( "Already running or has run or finished.", )); @@ -134,13 +133,13 @@ impl ReceiveFilesBubble { tokio::spawn(async move { let mut carrier = carrier; if let Err(e) = carrier.greet().await { - carrier.log(format!("start: Handshake failed: {}", e)); + carrier.log(format!("start: Handshake failed: {e}")); return; } let result = carrier.receive_files().await; if let Err(e) = result { - carrier.log(format!("start: File reception failed: {}", e)); + carrier.log(format!("start: File reception failed: {e}")); } else { carrier.log( "start: File reception completed successfully".to_string(), @@ -185,7 +184,7 @@ impl ReceiveFilesBubble { let running = self .is_running .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_running check: {}", running)); + self.log(format!("is_running check: {running}")); running } @@ -195,7 +194,7 @@ impl ReceiveFilesBubble { let finished = self .is_finished .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_finished check: {}", finished)); + self.log(format!("is_finished check: {finished}")); finished } @@ -204,7 +203,7 @@ impl ReceiveFilesBubble { let cancelled = self .is_cancelled .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_cancelled check: {}", cancelled)); + self.log(format!("is_cancelled check: {cancelled}")); cancelled } @@ -215,8 +214,7 @@ impl ReceiveFilesBubble { pub fn subscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); self.log(format!( - "subscribe: Subscribing new subscriber with ID: {}", - subscriber_id + "subscribe: Subscribing new subscriber with ID: {subscriber_id}" )); self.subscribers @@ -224,16 +222,14 @@ impl ReceiveFilesBubble { .unwrap() .insert(subscriber_id.clone(), subscriber); - self.log(format!("subscribe: Subscriber {} successfully subscribed. Total subscribers: {}", - subscriber_id, self.subscribers.read().unwrap().len())); + self.log(format!("subscribe: Subscriber {subscriber_id} successfully subscribed. Total subscribers: {}", self.subscribers.read().unwrap().len())); } /// Remove a previously registered subscriber. pub fn unsubscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); self.log(format!( - "unsubscribe: Unsubscribing subscriber with ID: {}", - subscriber_id + "unsubscribe: Unsubscribing subscriber with ID: {subscriber_id}" )); let removed = self @@ -243,21 +239,18 @@ impl ReceiveFilesBubble { .remove(&subscriber_id); if removed.is_some() { - self.log(format!("unsubscribe: Subscriber {} successfully unsubscribed. Remaining subscribers: {}", - subscriber_id, self.subscribers.read().unwrap().len())); + self.log(format!("unsubscribe: Subscriber {subscriber_id} successfully unsubscribed. Remaining subscribers: {}", self.subscribers.read().unwrap().len())); } else { - self.log(format!("unsubscribe: Subscriber {} was not found during unsubscribe operation", subscriber_id)); + self.log(format!("unsubscribe: Subscriber {subscriber_id} was not found during unsubscribe operation")); } } fn log(&self, message: String) { - self.subscribers - .read() - .unwrap() - .iter() - .for_each(|(id, subscriber)| { - subscriber.log(format!("[{}] {}", id, message)); - }); + self.subscribers.read().unwrap().iter().for_each( + |(_id, subscriber)| { + subscriber.log(message.clone()); + }, + ); } } @@ -423,37 +416,34 @@ impl Carrier { // Clean up completed tasks periodically while join_set.len() >= parallel_streams as usize { - if let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - // Downcast anyhow::Error to ConnectionError - if let Some(connection_err) = - err.downcast_ref::() - { - if connection_err == &expected_close { - break 'files_iterator; - } - } - return Err(err); + if let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + // Downcast anyhow::Error to ConnectionError + if let Some(connection_err) = + err.downcast_ref::() + && connection_err == &expected_close + { + break 'files_iterator; } + return Err(err); } } } - while let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - // Downcast anyhow::Error to ConnectionError - if let Some(connection_err) = - err.downcast_ref::() - { - if connection_err == &expected_close { - continue; - } - } - return Err(err); + while let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + // Downcast anyhow::Error to ConnectionError + if let Some(connection_err) = err.downcast_ref::() + && connection_err == &expected_close + { + continue; } + return Err(err); } - return Ok(()); + Ok(()) } /// Process a single unidirectional stream and emit receiving events per @@ -511,7 +501,7 @@ impl Carrier { let cancelled = self .is_cancelled .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_cancelled check: {}", cancelled)); + self.log(format!("is_cancelled check: {cancelled}")); cancelled } @@ -520,8 +510,8 @@ impl Carrier { .read() .unwrap() .iter() - .for_each(|(id, subscriber)| { - subscriber.log(format!("[{}] {}", id, message)); + .for_each(|(_, subscriber)| { + subscriber.log(message.clone()); }); } @@ -640,7 +630,7 @@ pub struct ReceiveFilesFile { /// Example: /// ```rust no_run /// use std::sync::Arc; -/// use dropx_receiver::{ +/// use arkdropx_receiver::{ /// receive_files, ReceiveFilesRequest, ReceiverProfile, ReceiverConfig, /// ReceiveFilesSubscriber, ReceiveFilesReceivingEvent, ReceiveFilesConnectingEvent, /// }; @@ -653,7 +643,7 @@ pub struct ReceiveFilesFile { /// println!("chunk for {}: {} bytes", e.id, e.data.len()); /// } /// fn notify_connecting(&self, e: ReceiveFilesConnectingEvent) { -/// println!("sender: {}, files: {}", e.sender.name, e.files.len()); +/// println!("sender: {}, files: {e}".sender.name, e.files.len()); /// } /// } /// diff --git a/drop-core/exchanges/sender/Cargo.toml b/drop-core/exchanges/sender/Cargo.toml index 0ee38131..0d3bc3d9 100644 --- a/drop-core/exchanges/sender/Cargo.toml +++ b/drop-core/exchanges/sender/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "dropx-sender" +name = "arkdropx-sender" version = "1.1.0" edition = "2024" [lib] -name = "dropx_sender" +name = "arkdropx_sender" bench = false [dependencies] -drop-entities = { path = "../../entities" } -dropx-common = { path = "../common" } +arkdrop-entities = { path = "../../entities" } +arkdropx-common = { path = "../common" } rand = "0.9.0" uuid = "1.16.0" iroh = "0.91.1" diff --git a/drop-core/exchanges/sender/src/lib.rs b/drop-core/exchanges/sender/src/lib.rs index 9d2e0cfe..3c994be6 100644 --- a/drop-core/exchanges/sender/src/lib.rs +++ b/drop-core/exchanges/sender/src/lib.rs @@ -19,7 +19,7 @@ mod send_files; -use drop_entities::Data; +use arkdrop_entities::Data; use std::sync::Arc; pub use send_files::*; @@ -64,6 +64,9 @@ pub trait SenderFileData: Send + Sync { /// Total length in bytes. fn len(&self) -> u64; + /// Checks if the data is empty (length is 0). + fn is_empty(&self) -> bool; + /// Read a single byte if available. fn read(&self) -> Option; @@ -80,15 +83,19 @@ struct SenderFileDataAdapter { } impl Data for SenderFileDataAdapter { fn len(&self) -> u64 { - return self.inner.len(); + self.inner.len() + } + + fn is_empty(&self) -> bool { + self.len() == 0 } fn read(&self) -> Option { - return self.inner.read(); + self.inner.read() } fn read_chunk(&self, size: u64) -> Vec { - return self.inner.read_chunk(size); + self.inner.read_chunk(size) } } diff --git a/drop-core/exchanges/sender/src/send_files.rs b/drop-core/exchanges/sender/src/send_files.rs index 605a2029..602d142d 100644 --- a/drop-core/exchanges/sender/src/send_files.rs +++ b/drop-core/exchanges/sender/src/send_files.rs @@ -9,8 +9,8 @@ mod handler; use crate::{SenderConfig, SenderFile, SenderFileDataAdapter, SenderProfile}; use anyhow::Result; +use arkdrop_entities::{File, Profile}; use chrono::{DateTime, Utc}; -use drop_entities::{File, Profile}; use handler::SendFilesHandler; use iroh::{Endpoint, Watcher, protocol::Router}; use iroh_base::ticket::NodeTicket; @@ -93,7 +93,7 @@ impl SendFilesBubble { } Err(e) => { self.handler - .log(format!("cancel: Error during cancellation: {}", e)); + .log(format!("cancel: Error during cancellation: {e}")); } } @@ -103,19 +103,22 @@ impl SendFilesBubble { /// Returns true when the router has been shut down or the handler has /// finished sending. If finished, it ensures the router is shut down. pub fn is_finished(&self) -> bool { - let router_shutdown = self.router.is_shutdown(); - let handler_finished = self.handler.is_finished(); - let is_finished = router_shutdown || handler_finished; + let router = self.router.clone(); + let is_router_shutdown = router.is_shutdown(); + let is_handler_finished = self.handler.is_finished(); + let is_finished = is_router_shutdown || is_handler_finished; - self.handler.log(format!("is_finished: Router shutdown: {}, Handler finished: {}, Overall finished: {}", - router_shutdown, handler_finished, is_finished)); + self.handler.log(format!("is_finished: Router shutdown: {is_router_shutdown}, Handler finished: {is_handler_finished}, Overall finished: {is_finished}")); if is_finished { self.handler.log( "is_finished: Transfer is finished, ensuring router shutdown" .to_string(), ); - let _ = self.router.shutdown(); + + tokio::spawn(async move { + let _ = router.shutdown().await; + }); } is_finished @@ -135,7 +138,7 @@ impl SendFilesBubble { let consumed = self.handler.is_consumed(); self.handler - .log(format!("is_connected: Handler consumed: {}", consumed)); + .log(format!("is_connected: Handler consumed: {consumed}")); consumed } @@ -152,8 +155,7 @@ impl SendFilesBubble { pub fn subscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); self.handler.log(format!( - "subscribe: Subscribing new subscriber with ID: {}", - subscriber_id + "subscribe: Subscribing new subscriber with ID: {subscriber_id}" )); self.handler.subscribe(subscriber); } @@ -162,8 +164,7 @@ impl SendFilesBubble { pub fn unsubscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); self.handler.log(format!( - "unsubscribe: Unsubscribing subscriber with ID: {}", - subscriber_id + "unsubscribe: Unsubscribing subscriber with ID: {subscriber_id}" )); self.handler.unsubscribe(subscriber); } @@ -207,8 +208,7 @@ pub async fn send_files(request: SendFilesRequest) -> Result { )); handler.log(format!( - "send_files: Starting file transfer initialization with {} files", - files_len + "send_files: Starting file transfer initialization with {files_len} files" )); handler.log(format!( "send_files: Chunk size configuration: {} bytes", @@ -227,15 +227,13 @@ pub async fn send_files(request: SendFilesRequest) -> Result { handler.log("send_files: Initializing node address".to_string()); let node_addr = endpoint.node_addr().initialized().await; handler.log(format!( - "send_files: Node address initialized: {:?}", - node_addr + "send_files: Node address initialized: {node_addr:?}" )); handler.log("send_files: Generating random confirmation code".to_string()); let confirmation: u8 = rand::rng().random_range(0..=99); handler.log(format!( - "send_files: Generated confirmation code: {}", - confirmation + "send_files: Generated confirmation code: {confirmation}" )); handler.log("send_files: Creating router with handler".to_string()); @@ -246,7 +244,7 @@ pub async fn send_files(request: SendFilesRequest) -> Result { .log("send_files: Router created and spawned successfully".to_string()); let ticket = NodeTicket::new(node_addr).to_string(); - handler.log(format!("send_files: Generated ticket: {}", ticket)); + handler.log(format!("send_files: Generated ticket: {ticket}")); handler.log( "send_files: File transfer initialization completed successfully" .to_string(), diff --git a/drop-core/exchanges/sender/src/send_files/handler.rs b/drop-core/exchanges/sender/src/send_files/handler.rs index d843aae1..5045df12 100644 --- a/drop-core/exchanges/sender/src/send_files/handler.rs +++ b/drop-core/exchanges/sender/src/send_files/handler.rs @@ -7,8 +7,8 @@ //! progress updates. use anyhow::Result; -use drop_entities::{File, Profile}; -use dropx_common::{ +use arkdrop_entities::{File, Profile}; +use arkdropx_common::{ handshake::{ HandshakeConfig, HandshakeFile, HandshakeProfile, NegotiatedConfig, ReceiverHandshake, SenderHandshake, @@ -56,6 +56,7 @@ pub trait SendFilesSubscriber: Send + Sync { /// - `remaining`: bytes left until completion for this file. #[derive(Clone)] pub struct SendFilesSendingEvent { + pub id: String, pub name: String, pub sent: u64, pub remaining: u64, @@ -111,14 +112,14 @@ impl SendFilesHandler { files: Vec, config: SenderConfig, ) -> Self { - return Self { + Self { is_consumed: AtomicBool::new(false), is_finished: Arc::new(AtomicBool::new(false)), profile, files: files.clone(), config, subscribers: Arc::new(RwLock::new(HashMap::new())), - }; + } } /// Returns true if a connection has already been accepted. @@ -128,7 +129,7 @@ impl SendFilesHandler { let consumed = self .is_consumed .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_consumed check: {}", consumed)); + self.log(format!("is_consumed check: {consumed}")); consumed } @@ -138,7 +139,7 @@ impl SendFilesHandler { let finished = self .is_finished .load(std::sync::atomic::Ordering::Relaxed); - self.log(format!("is_finished check: {}", finished)); + self.log(format!("is_finished check: {finished}")); finished } @@ -148,8 +149,8 @@ impl SendFilesHandler { .read() .unwrap() .iter() - .for_each(|(id, subscriber)| { - subscriber.log(format!("[{}] {}", id, message)); + .for_each(|(_, subscriber)| { + subscriber.log(message.clone()); }); } @@ -157,8 +158,7 @@ impl SendFilesHandler { pub fn subscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); self.log(format!( - "Subscribing new subscriber with ID: {}", - subscriber_id + "Subscribing new subscriber with ID: {subscriber_id}" )); self.subscribers @@ -176,10 +176,7 @@ impl SendFilesHandler { /// Unregisters a subscriber by its ID. pub fn unsubscribe(&self, subscriber: Arc) { let subscriber_id = subscriber.get_id(); - self.log(format!( - "Unsubscribing subscriber with ID: {}", - subscriber_id - )); + self.log(format!("Unsubscribing subscriber with ID: {subscriber_id}")); let removed = self .subscribers @@ -188,11 +185,10 @@ impl SendFilesHandler { .remove(&subscriber_id); if removed.is_some() { - self.log(format!("Subscriber {} successfully unsubscribed. Remaining subscribers: {}", subscriber_id, self.subscribers.read().unwrap().len())); + self.log(format!("Subscriber {subscriber_id} successfully unsubscribed. Remaining subscribers: {}", self.subscribers.read().unwrap().len())); } else { self.log(format!( - "Subscriber {} was not found during unsubscribe operation", - subscriber_id + "Subscriber {subscriber_id} was not found during unsubscribe operation" )); } } @@ -255,11 +251,11 @@ impl ProtocolHandler for SendFilesHandler { async move { let mut carrier = carrier; - if let Err(_) = carrier.greet().await { + if (carrier.greet().await).is_err() { return Err(iroh::protocol::AcceptError::NotAllowed {}); } - if let Err(_) = carrier.send_files().await { + if (carrier.send_files().await).is_err() { return Err(iroh::protocol::AcceptError::NotAllowed {}); } @@ -411,26 +407,25 @@ impl Carrier { }); // Limit concurrent streams to negotiated number - if join_set.len() >= parallel_streams as usize { - if let Some(result) = join_set.join_next().await { - if let Err(err) = result? { - self.log(format!("send_files: Stream failed: {}", err)); - return Err(err); - } - } + if join_set.len() >= parallel_streams as usize + && let Some(result) = join_set.join_next().await + && let Err(err) = result? + { + self.log(format!("send_files: Stream failed: {err}")); + return Err(err); } } // Wait for all remaining streams to complete and update final progress while let Some(result) = join_set.join_next().await { if let Err(err) = result? { - self.log(format!("send_single_file: Stream failed: {}", err)); + self.log(format!("send_single_file: Stream failed: {err}")); return Err(err); } } self.log("send_files: All files transferred successfully".to_string()); - return Ok(()); + Ok(()) } /// Streams a single file in JSON-framed chunks: @@ -442,7 +437,7 @@ impl Carrier { connection: Connection, subscribers: Arc>>>, ) -> Result<()> { - let total_len = file.data.len() as u64; + let total_len = file.data.len(); let mut sent = 0u64; let mut remaining = total_len; let mut chunk_buffer = @@ -450,7 +445,7 @@ impl Carrier { let mut uni = connection.open_uni().await?; - Self::notify_progress(&file.name, sent, remaining, subscribers.clone()); + Self::notify_progress(&file, sent, remaining, subscribers.clone()); loop { chunk_buffer.clear(); @@ -475,18 +470,13 @@ impl Carrier { sent += data_len; remaining = remaining.saturating_sub(data_len); - Self::notify_progress( - &file.name, - sent, - remaining, - subscribers.clone(), - ); + Self::notify_progress(&file, sent, remaining, subscribers.clone()); } uni.finish()?; uni.stopped().await?; - return Ok(()); + Ok(()) } /// Marks the handler as finished and closes the connection with a code and @@ -507,24 +497,23 @@ impl Carrier { /// Internal logger that prefixes subscriber IDs. fn log(&self, message: String) { - self.subscribers - .read() - .unwrap() - .iter() - .for_each(|(id, subscriber)| { - subscriber.log(format!("[{}] {}", id, message)); - }); + self.subscribers.read().unwrap().iter().for_each( + |(_id, subscriber)| { + subscriber.log(message.clone()); + }, + ); } /// Notifies all subscribers about the current per-file progress. fn notify_progress( - name: &str, + file: &File, sent: u64, remaining: u64, subscribers: Arc>>>, ) { let event = SendFilesSendingEvent { - name: name.to_string(), + id: file.id.clone(), + name: file.name.clone(), sent, remaining, }; diff --git a/drop-core/main/Cargo.toml b/drop-core/main/Cargo.toml new file mode 100644 index 00000000..6c19c11d --- /dev/null +++ b/drop-core/main/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "arkdrop" +version = "0.1.0" +edition = "2024" + +[[bin]] +bench = false +name = "arkdrop" +path = "src/main.rs" + +[dependencies] +arkdrop-common = { path = "../common" } +arkdrop-cli = { path = "../cli" } +arkdrop-tui = { path = "../tui" } + +clap = "4.5.47" +tokio = "1.47.1" +anyhow = "1.0.99" diff --git a/drop-core/main/src/main.rs b/drop-core/main/src/main.rs new file mode 100644 index 00000000..9d8f4871 --- /dev/null +++ b/drop-core/main/src/main.rs @@ -0,0 +1,15 @@ +use anyhow::Result; +use arkdrop_cli::{build_cli, run_cli}; +use arkdrop_tui::run_tui; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = build_cli(); + let matches = cli.get_matches(); + + if !matches.args_present() && matches.subcommand().is_none() { + return run_tui(); + } + + return run_cli().await; +} diff --git a/drop-core/tui/Cargo.toml b/drop-core/tui/Cargo.toml new file mode 100644 index 00000000..0975ef0a --- /dev/null +++ b/drop-core/tui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "arkdrop-tui" +version = "1.0.0" +edition = "2024" +description = "A TUI tool for sending and receiving files" +authors = ["ARK Builders"] +license = "MIT" +readme = "README.md" +repository = "https://github.com/Ark-Builders/ark-core" +categories = ["command-line-utilities"] +keywords = ["tui", "file-transfer", "data-transfer", "p2p"] + +[lib] +bench = false +name = "arkdrop_tui" + +[[bin]] +bench = false +name = "arkdrop-tui" +path = "src/main.rs" + +[dependencies] +arkdrop-common = { path = "../common" } +arkdropx-sender = { path = "../exchanges/sender" } +arkdropx-receiver = { path = "../exchanges/receiver" } + +toml = "0.9.5" +tokio = "1.47.1" +anyhow = "1.0.99" +crossterm = "0.28" +ratatui = "0.29.0" +base64 = "0.22.1" +qrcode = "0.14.1" +serde = "1.0.219" +uuid = "1.18.1" diff --git a/drop-core/tui/src/apps/config.rs b/drop-core/tui/src/apps/config.rs new file mode 100644 index 00000000..e4c44b11 --- /dev/null +++ b/drop-core/tui/src/apps/config.rs @@ -0,0 +1,924 @@ +use std::{ + path::PathBuf, + sync::{ + Arc, RwLock, + atomic::{AtomicBool, AtomicUsize}, + }, +}; + +use crate::{ + App, AppBackend, AppFileBrowserSaveEvent, AppFileBrowserSubscriber, + BrowserMode, ControlCapture, SortMode, +}; +use arkdrop_common::{AppConfig, transform_to_base64}; +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode, KeyModifiers}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +#[derive(Clone, PartialEq)] +enum ConfigField { + AvatarName, + AvatarFile, + OutputDirectory, +} + +impl ConfigField { + fn title(&self) -> &'static str { + match self { + ConfigField::AvatarName => "Display Name", + ConfigField::AvatarFile => "Avatar Image", + ConfigField::OutputDirectory => "Default Output Directory", + } + } + + fn icon(&self) -> &'static str { + match self { + ConfigField::AvatarName => "👤", + ConfigField::AvatarFile => "🖼️", + ConfigField::OutputDirectory => "📁", + } + } + + fn description(&self) -> &'static str { + match self { + ConfigField::AvatarName => "Your display name for file transfers", + ConfigField::AvatarFile => "Profile picture file path", + ConfigField::OutputDirectory => "Default folder for received files", + } + } +} + +pub struct ConfigApp { + b: Arc, + + // UI State + menu: RwLock, + selected_field: AtomicUsize, + + // Configuration values (matching AppConfig structure) + avatar_name: RwLock>, + avatar_file: RwLock>, + out_dir: RwLock>, + + // UI state for avatar preview + avatar_base64_preview: Arc>>, + + // Status and feedback + status_message: Arc>, + is_processing: Arc, + + // File browser integration + awaiting_browser_result: RwLock>, + + // Text input state for avatar name + is_editing_name: Arc, + name_input_buffer: Arc>, + name_cursor_position: Arc, +} + +impl App for ConfigApp { + fn draw(&self, f: &mut Frame, area: Rect) { + let blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(5), // Title and description + Constraint::Min(12), // Configuration fields + Constraint::Length(4), // Status and help + ]) + .split(area); + + self.draw_header(f, blocks[0]); + self.draw_config_fields(f, blocks[1]); + self.draw_footer(f, blocks[2]); + } + + fn handle_control(&self, ev: &Event) -> Option { + let is_editing_name = self.is_editing_name(); + + if is_editing_name { + return self.handle_name_input_control(ev); + } else { + return self.handle_navigation_control(ev); + } + } +} + +impl AppFileBrowserSubscriber for ConfigApp { + fn on_save(&self, event: AppFileBrowserSaveEvent) { + let awaiting_field = self + .awaiting_browser_result + .write() + .unwrap() + .take(); + + if let Some(field) = awaiting_field { + if let Some(selected_path) = event.selected_files.first() { + match field { + ConfigField::AvatarFile => { + self.set_avatar_file(selected_path.clone()); + self.process_avatar_preview(selected_path.clone()); + } + ConfigField::OutputDirectory => { + self.set_out_dir(selected_path.clone()); + self.set_status_message(&format!( + "Output directory set to: {}", + selected_path.display() + )); + } + _ => {} + } + } + } + } + + fn on_cancel(&self) { + self.b.get_navigation().go_back(); + } +} + +impl ConfigApp { + pub fn new(b: Arc) -> Self { + let config = b.get_config(); + + let mut menu = ListState::default(); + menu.select(Some(0)); + + let app = Self { + b, + + menu: RwLock::new(menu), + selected_field: AtomicUsize::new(0), + + avatar_name: RwLock::new(config.avatar_name.clone()), + avatar_file: RwLock::new(config.avatar_file.clone()), + out_dir: RwLock::new(config.out_dir.clone()), + + avatar_base64_preview: Arc::new(RwLock::new(None)), + + status_message: Arc::new(RwLock::new( + "Configure your profile and transfer preferences".to_string(), + )), + is_processing: Arc::new(AtomicBool::new(false)), + + awaiting_browser_result: RwLock::new(None), + + // Text input state + is_editing_name: Arc::new(AtomicBool::new(false)), + name_input_buffer: Arc::new(RwLock::new(String::new())), + name_cursor_position: Arc::new(AtomicUsize::new(0)), + }; + + // Generate preview for existing avatar file + if let Some(avatar_path) = &config.avatar_file { + app.process_avatar_preview(avatar_path.clone()); + } + + app + } + + fn get_config_fields(&self) -> Vec { + vec![ + ConfigField::AvatarName, + ConfigField::AvatarFile, + ConfigField::OutputDirectory, + ] + } + + fn handle_navigation_control(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + if has_ctrl { + match key.code { + KeyCode::Char('s') | KeyCode::Char('S') => { + self.save_configuration(); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.reset_to_defaults(); + } + KeyCode::Char('c') | KeyCode::Char('C') => { + self.b.get_navigation().go_back(); + } + _ => return None, + } + } else { + match key.code { + KeyCode::Up => self.navigate_up(), + KeyCode::Down => self.navigate_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + self.activate_current_field() + } + KeyCode::Esc => { + if self.is_processing() { + self.set_status_message("Operation cancelled"); + self.set_processing(false); + } else { + self.b.get_navigation().go_back(); + } + } + _ => return None, + } + } + + return Some(ControlCapture::new(ev)); + } + + None + } + + fn navigate_up(&self) { + let fields = self.get_config_fields(); + let current = self + .selected_field + .load(std::sync::atomic::Ordering::Relaxed); + let new_index = if current > 0 { + current - 1 + } else { + fields.len() - 1 + }; + + self.selected_field + .store(new_index, std::sync::atomic::Ordering::Relaxed); + self.menu.write().unwrap().select(Some(new_index)); + } + + fn navigate_down(&self) { + let fields = self.get_config_fields(); + let current = self + .selected_field + .load(std::sync::atomic::Ordering::Relaxed); + let new_index = if current < fields.len() - 1 { + current + 1 + } else { + 0 + }; + + self.selected_field + .store(new_index, std::sync::atomic::Ordering::Relaxed); + self.menu.write().unwrap().select(Some(new_index)); + } + + fn activate_current_field(&self) { + let fields = self.get_config_fields(); + let current = self + .selected_field + .load(std::sync::atomic::Ordering::Relaxed); + + if let Some(field) = fields.get(current) { + match field { + ConfigField::AvatarName => { + self.edit_avatar_name(); + } + ConfigField::AvatarFile => { + self.open_avatar_browser(); + } + ConfigField::OutputDirectory => { + self.open_directory_browser(); + } + } + } + } + + fn edit_avatar_name(&self) { + // Initialize input buffer with current name or empty string + let current_name = self.get_avatar_name().unwrap_or_default(); + *self.name_input_buffer.write().unwrap() = current_name.clone(); + + // Set cursor to end of current text + self.name_cursor_position + .store(current_name.len(), std::sync::atomic::Ordering::Relaxed); + + // Enter editing mode + self.is_editing_name + .store(true, std::sync::atomic::Ordering::Relaxed); + self.set_status_message( + "Editing display name - Enter to save, Esc to cancel", + ); + } + + fn is_editing_name(&self) -> bool { + self.is_editing_name + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn handle_name_input_control(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + match key.code { + KeyCode::Enter => { + self.finish_editing_name(); + } + KeyCode::Esc => { + self.cancel_editing_name(); + } + KeyCode::Backspace => { + self.handle_backspace(); + } + KeyCode::Delete => { + self.handle_delete(); + } + KeyCode::Left => { + if has_ctrl { + self.move_cursor_left_by_word(); + } else { + self.move_cursor_left(); + } + } + KeyCode::Right => { + if has_ctrl { + self.move_cursor_right_by_word(); + } else { + self.move_cursor_right(); + } + } + KeyCode::Home => { + self.move_cursor_home(); + } + KeyCode::End => { + self.move_cursor_end(); + } + KeyCode::Char(c) => { + self.insert_char(c); + } + _ => return None, + } + + return Some(ControlCapture::new(ev)); + } + + None + } + + fn finish_editing_name(&self) { + let input_text = self.name_input_buffer.read().unwrap().clone(); + let trimmed_text = input_text.trim(); + + if trimmed_text.is_empty() { + *self.avatar_name.write().unwrap() = None; + self.set_status_message("Display name cleared"); + } else { + *self.avatar_name.write().unwrap() = Some(trimmed_text.to_string()); + self.set_status_message(&format!( + "Display name set to: {}", + trimmed_text + )); + } + + // Exit editing mode + self.is_editing_name + .store(false, std::sync::atomic::Ordering::Relaxed); + } + + fn cancel_editing_name(&self) { + // Exit editing mode without saving + self.is_editing_name + .store(false, std::sync::atomic::Ordering::Relaxed); + self.set_status_message("Name editing cancelled"); + } + + fn insert_char(&self, c: char) { + let mut buffer = self.name_input_buffer.write().unwrap(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + // Insert character at cursor position + buffer.insert(cursor_pos, c); + + // Move cursor forward + self.name_cursor_position + .store(cursor_pos + 1, std::sync::atomic::Ordering::Relaxed); + } + + fn handle_backspace(&self) { + let mut buffer = self.name_input_buffer.write().unwrap(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos > 0 { + buffer.remove(cursor_pos - 1); + self.name_cursor_position + .store(cursor_pos - 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn handle_delete(&self) { + let mut buffer = self.name_input_buffer.write().unwrap(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos < buffer.len() { + buffer.remove(cursor_pos); + } + } + + fn move_cursor_left(&self) { + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + if cursor_pos > 0 { + self.name_cursor_position + .store(cursor_pos - 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn move_cursor_left_by_word(&self) { + let buffer = self.name_input_buffer.read().unwrap(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if cursor_pos == 0 { + return; + } + + let mut new_pos = cursor_pos; + let chars: Vec = buffer.chars().collect(); + + // Skip whitespace backwards + while new_pos > 0 && chars[new_pos - 1].is_whitespace() { + new_pos -= 1; + } + + // Skip word characters backwards + while new_pos > 0 && !chars[new_pos - 1].is_whitespace() { + new_pos -= 1; + } + + self.name_cursor_position + .store(new_pos, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_right_by_word(&self) { + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + let buffer = self.name_input_buffer.read().unwrap(); + let last_pos = buffer.len() - 1; + + if cursor_pos == last_pos { + return; + } + + let mut new_pos = cursor_pos; + let chars: Vec = buffer.chars().collect(); + + // Skip whitespace forward + while new_pos < last_pos && chars[new_pos + 1].is_whitespace() { + new_pos += 1; + } + + // Skip word characters forward + while new_pos < last_pos && !chars[new_pos + 1].is_whitespace() { + new_pos += 1; + } + + self.name_cursor_position + .store(new_pos, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_right(&self) { + let buffer = self.name_input_buffer.read().unwrap(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + if cursor_pos < buffer.len() { + self.name_cursor_position + .store(cursor_pos + 1, std::sync::atomic::Ordering::Relaxed); + } + } + + fn move_cursor_home(&self) { + self.name_cursor_position + .store(0, std::sync::atomic::Ordering::Relaxed); + } + + fn move_cursor_end(&self) { + let buffer = self.name_input_buffer.read().unwrap(); + self.name_cursor_position + .store(buffer.len(), std::sync::atomic::Ordering::Relaxed); + } + + fn open_avatar_browser(&self) { + self.set_status_message("Opening file browser for avatar selection..."); + *self.awaiting_browser_result.write().unwrap() = + Some(ConfigField::AvatarFile); + + let file_browser_manager = self.b.get_file_browser_manager(); + file_browser_manager.open_file_browser(crate::OpenFileBrowserRequest { + from: crate::Page::Config, + mode: BrowserMode::SelectFile, + sort: SortMode::Name, + }); + } + + fn open_directory_browser(&self) { + self.set_status_message("Opening directory browser..."); + *self.awaiting_browser_result.write().unwrap() = + Some(ConfigField::OutputDirectory); + + let file_browser_manager = self.b.get_file_browser_manager(); + file_browser_manager.open_file_browser(crate::OpenFileBrowserRequest { + from: crate::Page::Config, + mode: BrowserMode::SelectDirectory, + sort: SortMode::Name, + }); + } + + fn process_avatar_preview(&self, path: PathBuf) { + self.set_processing(true); + self.set_status_message("Processing avatar preview..."); + + // Process image in a separate thread to avoid blocking UI + let path_clone = path.clone(); + let status_message = self.status_message.clone(); + let avatar_base64_preview = self.avatar_base64_preview.clone(); + let is_processing = self.is_processing.clone(); + + std::thread::spawn(move || { + match transform_to_base64(&path_clone) { + Ok(base64_string) => { + *avatar_base64_preview.write().unwrap() = + Some(base64_string); + *status_message.write().unwrap() = format!( + "Avatar file set: {}", + path_clone + .file_name() + .unwrap_or_default() + .to_string_lossy() + ); + } + Err(e) => { + *status_message.write().unwrap() = + format!("Failed to process image: {}", e); + } + } + is_processing.store(false, std::sync::atomic::Ordering::Relaxed); + }); + } + + fn save_configuration(&self) { + self.set_processing(true); + self.set_status_message("Saving configuration..."); + + let avatar_name = self.avatar_name.read().unwrap().clone(); + let avatar_file = self.avatar_file.read().unwrap().clone(); + let out_dir = self.out_dir.read().unwrap().clone(); + + let config = AppConfig { + avatar_name, + avatar_file, + out_dir, + }; + + match config.save() { + Ok(_) => { + self.set_status_message("Configuration saved successfully!"); + } + Err(e) => { + self.set_status_message(&format!( + "Failed to save configuration: {}", + e + )); + } + } + + self.set_processing(false); + } + + fn reset_to_defaults(&self) { + *self.avatar_name.write().unwrap() = None; + *self.avatar_file.write().unwrap() = None; + *self.out_dir.write().unwrap() = None; + *self.avatar_base64_preview.write().unwrap() = None; + + self.set_status_message("Configuration reset to defaults"); + } + + fn set_avatar_file(&self, path: PathBuf) { + *self.avatar_file.write().unwrap() = Some(path); + } + + fn set_out_dir(&self, path: PathBuf) { + *self.out_dir.write().unwrap() = Some(path); + } + + fn set_status_message(&self, message: &str) { + *self.status_message.write().unwrap() = message.to_string(); + } + + fn set_processing(&self, processing: bool) { + self.is_processing + .store(processing, std::sync::atomic::Ordering::Relaxed); + } + + fn is_processing(&self) -> bool { + self.is_processing + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn get_avatar_name(&self) -> Option { + self.avatar_name.read().unwrap().clone() + } + + fn get_avatar_file(&self) -> Option { + self.avatar_file.read().unwrap().clone() + } + + fn get_out_dir(&self) -> Option { + self.out_dir.read().unwrap().clone() + } + + fn get_avatar_base64_preview(&self) -> Option { + self.avatar_base64_preview.read().unwrap().clone() + } + + fn get_status_message(&self) -> String { + self.status_message.read().unwrap().clone() + } + + fn draw_header(&self, f: &mut Frame, area: Rect) { + let has_name = self.get_avatar_name().is_some(); + let has_avatar = self.get_avatar_file().is_some(); + let has_out_dir = self.get_out_dir().is_some(); + + let completion_count = [has_name, has_avatar, has_out_dir] + .iter() + .filter(|&&x| x) + .count(); + + let header_content = vec![ + Line::from(vec![ + Span::styled("⚙️ ", Style::default().fg(Color::Blue).bold()), + Span::styled( + "Configuration", + Style::default().fg(Color::White).bold(), + ), + Span::styled( + format!(" • {}/3 configured", completion_count), + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Configure your profile and transfer preferences", + Style::default().fg(Color::Gray).italic(), + )]), + ]; + + let header_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .title(" Settings ") + .title_style(Style::default().fg(Color::White).bold()); + + let header = Paragraph::new(header_content) + .block(header_block) + .alignment(Alignment::Left); + + f.render_widget(header, area); + } + + fn draw_config_fields(&self, f: &mut Frame, area: Rect) { + let fields = self.get_config_fields(); + let current_selection = self + .selected_field + .load(std::sync::atomic::Ordering::Relaxed); + + let field_items: Vec = fields + .iter() + .enumerate() + .map(|(index, field)| { + let is_selected = index == current_selection; + let value_text = self.get_field_value_display(field); + let is_configured = self.is_field_configured(field); + + let status_icon = if is_configured { + "✅" + } else { + "⚪" + }; + let value_color = if is_configured { + Color::Green + } else { + Color::Gray + }; + + let title_line = Line::from(vec![ + Span::styled( + format!("{} ", status_icon), + Style::default().fg(if is_configured { + Color::Green + } else { + Color::Gray + }), + ), + Span::styled( + format!("{} ", field.icon()), + Style::default().fg(Color::Blue), + ), + Span::styled( + field.title(), + Style::default() + .fg(if is_selected { + Color::White + } else { + Color::LightBlue + }) + .bold(), + ), + ]); + + let value_line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(value_text, Style::default().fg(value_color)), + ]); + + let description_line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + field.description(), + Style::default().fg(Color::DarkGray).italic(), + ), + ]); + + ListItem::new(vec![title_line, value_line, description_line]) + .style(if is_selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }) + }) + .collect(); + + let fields_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::White)) + .title(" Profile Settings ") + .title_style(Style::default().fg(Color::White).bold()); + + let fields_list = List::new(field_items) + .block(fields_block) + .highlight_style( + Style::default() + .bg(Color::Blue) + .fg(Color::Black) + .bold(), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget( + fields_list, + area, + &mut self.menu.write().unwrap(), + ); + } + + fn draw_footer(&self, f: &mut Frame, area: Rect) { + let status_message = self.get_status_message(); + let is_processing = self.is_processing(); + let is_editing = self.is_editing_name(); + + let (status_icon, status_color) = if is_processing { + ("⏳", Color::Yellow) + } else if is_editing { + ("✏️", Color::Green) + } else { + ("ℹ️", Color::Blue) + }; + + let help_line = if is_editing { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Green).bold()), + Span::styled(": Save • ", Style::default().fg(Color::Gray)), + Span::styled("ESC", Style::default().fg(Color::Red).bold()), + Span::styled(": Cancel • ", Style::default().fg(Color::Gray)), + Span::styled("CTRL-A", Style::default().fg(Color::Cyan).bold()), + Span::styled(": Home • ", Style::default().fg(Color::Gray)), + Span::styled("CTRL-E", Style::default().fg(Color::Cyan).bold()), + Span::styled(": End • ", Style::default().fg(Color::Gray)), + Span::styled( + "CTRL-U", + Style::default().fg(Color::Yellow).bold(), + ), + Span::styled(": Clear", Style::default().fg(Color::Gray)), + ]) + } else { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Cyan).bold()), + Span::styled(": Edit • ", Style::default().fg(Color::Gray)), + Span::styled( + "CTRL-S", + Style::default().fg(Color::Green).bold(), + ), + Span::styled(": Save • ", Style::default().fg(Color::Gray)), + Span::styled( + "CTRL-R", + Style::default().fg(Color::Yellow).bold(), + ), + Span::styled(": Reset • ", Style::default().fg(Color::Gray)), + Span::styled("ESC", Style::default().fg(Color::Red).bold()), + Span::styled(": Back", Style::default().fg(Color::Gray)), + ]) + }; + + let footer_content = vec![ + Line::from(vec![ + Span::styled( + format!("{} ", status_icon), + Style::default().fg(status_color), + ), + Span::styled(status_message, Style::default().fg(Color::White)), + ]), + help_line, + ]; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Gray)) + .title(" Status & Controls ") + .title_style(Style::default().fg(Color::White).bold()); + + let footer = Paragraph::new(footer_content) + .block(footer_block) + .alignment(Alignment::Center); + + f.render_widget(footer, area); + } + + fn get_field_value_display(&self, field: &ConfigField) -> String { + match field { + ConfigField::AvatarName => { + if self.is_editing_name() && field == &ConfigField::AvatarName { + // Show input buffer with cursor when editing + let buffer = self.name_input_buffer.read().unwrap().clone(); + let cursor_pos = self + .name_cursor_position + .load(std::sync::atomic::Ordering::Relaxed); + + if buffer.is_empty() { + "│ (typing...)".to_string() + } else { + // Insert cursor indicator + let mut display = buffer.clone(); + if cursor_pos <= display.len() { + display.insert(cursor_pos, '│'); + } + display + } + } else if let Some(name) = self.get_avatar_name() { + name + } else { + "Not set".to_string() + } + } + ConfigField::AvatarFile => { + if let Some(path) = self.get_avatar_file() { + let has_preview = + self.get_avatar_base64_preview().is_some(); + format!( + "{} {}", + path.file_name() + .unwrap_or_default() + .to_string_lossy(), + if has_preview { + "(preview ready)" + } else { + "(processing...)" + } + ) + } else { + "No avatar file selected".to_string() + } + } + ConfigField::OutputDirectory => { + if let Some(dir) = self.get_out_dir() { + dir.to_string_lossy().to_string() + } else { + "Use system default".to_string() + } + } + } + } + + fn is_field_configured(&self, field: &ConfigField) -> bool { + match field { + ConfigField::AvatarName => self.get_avatar_name().is_some(), + ConfigField::AvatarFile => self.get_avatar_file().is_some(), + ConfigField::OutputDirectory => self.get_out_dir().is_some(), + } + } +} diff --git a/drop-core/tui/src/apps/file_browser.rs b/drop-core/tui/src/apps/file_browser.rs new file mode 100644 index 00000000..e4ee8439 --- /dev/null +++ b/drop-core/tui/src/apps/file_browser.rs @@ -0,0 +1,744 @@ +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode, KeyModifiers}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; +use std::{ + env, + fs::{self, DirEntry}, + ops::Deref, + path::PathBuf, + rc::Rc, + sync::{Arc, RwLock, atomic::AtomicBool}, + time::SystemTime, +}; + +use crate::{ + App, AppBackend, AppFileBrowser, AppFileBrowserSaveEvent, + AppFileBrowserSubscriber, BrowserMode, ControlCapture, SortMode, +}; + +#[derive(Clone, Debug, PartialEq)] +struct FileItem { + name: String, + path: PathBuf, + size: Option, + modified: Option, + is_hidden: bool, + is_selected: bool, + is_directory: bool, +} + +pub struct FileBrowserApp { + b: Arc, + + menu: RwLock, + + current_path: RwLock, + items: RwLock>, + sort: RwLock, + + mode: RwLock, + selected_files_in: RwLock>, + + has_hidden_items: AtomicBool, + enforced_extensions: RwLock>, + + // TODO: extra | implement dynamic filter based on user's input + filter_in: RwLock, + + sub: RwLock>>, +} + +impl App for FileBrowserApp { + fn draw(&self, f: &mut Frame, area: Rect) { + let blocks = self.get_layout_blocks(area); + + self.refresh(); + self.draw_header(f, blocks[0]); + self.draw_file_list(f, blocks[1]); + self.draw_footer_with_help(f, blocks[2]); + } + + fn handle_control(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + let has_ctrl = key.modifiers == KeyModifiers::CONTROL; + + match key.code { + KeyCode::Up => { + self.go_up(); + } + KeyCode::Down => { + self.go_down(); + } + KeyCode::Enter => self.enter_current_menu_item(), + KeyCode::Char(' ') => self.select_current_menu_item(), + KeyCode::Esc => { + self.on_save(); + } + KeyCode::Char('s') | KeyCode::Char('S') => { + if has_ctrl { + self.on_save() + } + } + KeyCode::Char('h') | KeyCode::Char('H') => { + if has_ctrl { + self.toggle_hidden() + } + } + KeyCode::Char('j') | KeyCode::Char('J') => { + if has_ctrl { + self.cycle_sort_mode() + } + } + KeyCode::Char('c') | KeyCode::Char('C') => { + if has_ctrl { + self.b.get_navigation().go_back(); + } + } + KeyCode::Char('k') | KeyCode::Char('K') => { + if has_ctrl { + self.reset(); + } + } + _ => return None, + } + + return Some(ControlCapture::new(ev)); + } + + None + } +} + +impl AppFileBrowser for FileBrowserApp { + fn set_subscriber(&self, sub: Arc) { + self.sub.write().unwrap().replace(sub); + } + + fn pop_subscriber(&self) { + self.sub.write().unwrap().take(); + } + + fn get_selected_files(&self) -> Vec { + self.selected_files_in.read().unwrap().clone() + } + + fn select_file(&self, path: PathBuf) { + self.selected_files_in.write().unwrap().push(path); + } + + fn deselect_file(&self, file: PathBuf) { + let mut selected_files = self.selected_files_in.write().unwrap(); + let selected_file_index = + selected_files.iter().position(|f| *f == file); + + if let Some(selected_file_index) = selected_file_index { + selected_files.remove(selected_file_index); + } + } + + fn set_mode(&self, mode: BrowserMode) { + *self.mode.write().unwrap() = mode; + } + + fn set_sort(&self, sort: SortMode) { + *self.sort.write().unwrap() = sort; + } + + fn set_current_path(&self, path: PathBuf) { + if path.exists() { + *self.current_path.write().unwrap() = path; + } else { + // TODO: info | log exception on TUI + } + } + + fn clear_selection(&self) { + self.selected_files_in.write().unwrap().clear(); + for item in self.items.write().unwrap().iter_mut() { + item.is_selected = false; + } + } +} + +impl FileBrowserApp { + pub fn new(b: Arc) -> Self { + let default_start_path = + env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + + Self { + b, + + menu: RwLock::new(ListState::default()), + + current_path: RwLock::new(default_start_path), + items: RwLock::new(Vec::new()), + sort: RwLock::new(SortMode::Name), + + mode: RwLock::new(BrowserMode::SelectMultiFiles), + selected_files_in: RwLock::new(Vec::new()), + + has_hidden_items: AtomicBool::new(false), + enforced_extensions: RwLock::new(Vec::new()), + + filter_in: RwLock::new(String::new()), + + sub: RwLock::new(None), + } + } + + fn refresh(&self) { + self.refresh_items(); + self.refresh_menu(); + } + + fn refresh_menu(&self) { + let items = self.items.read().unwrap(); + let mut menu = self.menu.write().unwrap(); + + if items.is_empty() { + menu.select(None); + } else if menu.selected().is_none() { + menu.select(Some(0)); + } + } + + fn sort_items(&self, items: &mut Vec) { + items.sort_by(|a, b| { + // Always put directories first + match (a.is_directory, b.is_directory) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => { + // Then sort by the selected criteria + match self.sort.read().unwrap().deref() { + SortMode::Name => { + a.name.to_lowercase().cmp(&b.name.to_lowercase()) + } + SortMode::Size => { + let a_size = a.size.unwrap_or(0); + let b_size = b.size.unwrap_or(0); + b_size.cmp(&a_size) // Descending order + } + SortMode::Modified => match (a.modified, b.modified) { + (Some(a_time), Some(b_time)) => b_time.cmp(&a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.name.cmp(&b.name), + }, + SortMode::Type => { + let a_ext = a.path.extension().unwrap_or_default(); + let b_ext = b.path.extension().unwrap_or_default(); + a_ext.cmp(&b_ext) + } + } + } + } + }); + } + + fn go_up(&self) { + let items = self.items.read().unwrap(); + + if items.is_empty() { + return; + } + + let mut menu = self.menu.write().unwrap(); + let selected = menu.selected().unwrap_or(0); + let last_index = items.len() - 1; + + if selected > 0 { + menu.select(Some(selected - 1)); + } else { + menu.select(Some(last_index)); + } + } + + fn go_down(&self) { + let items = self.items.read().unwrap(); + + if items.is_empty() { + return; + } + + let mut menu = self.menu.write().unwrap(); + let selected = menu.selected().unwrap_or(0); + let last_index = items.len() - 1; + + if selected < last_index { + menu.select(Some(selected + 1)); + } else { + menu.select(Some(0)); + } + } + + fn toggle_hidden(&self) { + let current = self + .has_hidden_items + .load(std::sync::atomic::Ordering::Relaxed); + self.has_hidden_items + .store(!current, std::sync::atomic::Ordering::Relaxed); + } + + fn cycle_sort_mode(&self) { + let mut sort_by = self.sort.write().unwrap(); + + *sort_by = match sort_by.deref() { + SortMode::Name => SortMode::Size, + SortMode::Size => SortMode::Modified, + SortMode::Modified => SortMode::Type, + SortMode::Type => SortMode::Name, + }; + } + + fn get_menu(&self) -> ListState { + self.menu.read().unwrap().clone() + } + + fn enter_current_menu_item(&self) { + let menu = self.get_menu(); + let items = self.get_items(); + + if let Some(current_index) = menu.selected() { + if let Some(item) = items.get(current_index) { + if item.is_directory { + self.enter_item_path(item); + } + } + } + } + + fn enter_item_path(&self, item: &FileItem) { + *self.current_path.write().unwrap() = item.path.clone(); + + self.menu.write().unwrap().select(Some(0)); + } + + fn select_current_menu_item(&self) { + let mode = self.get_mode(); + let menu = self.get_menu(); + + if let Some(item_idx) = menu.selected() { + if let Some(item) = self.items.write().unwrap().get_mut(item_idx) { + match mode { + BrowserMode::SelectFile => { + self.select_file(item); + self.on_save(); + } + BrowserMode::SelectDirectory => { + self.select_dir(item); + self.on_save(); + } + BrowserMode::SelectMultiFiles => { + self.select_file(item); + } + } + } + } + } + + fn reset(&self) { + self.filter_in.write().unwrap().clear(); + self.selected_files_in.write().unwrap().clear(); + + for item in self.items.write().unwrap().iter_mut() { + item.is_selected = false; + } + } + + fn get_current_path(&self) -> PathBuf { + self.current_path.read().unwrap().clone() + } + + fn is_extension_valid(&self, name: &String) -> bool { + let enforced_extensions = self.enforced_extensions.read().unwrap(); + if enforced_extensions.is_empty() { + return true; + } + return enforced_extensions + .iter() + .any(|ee| name.ends_with(&format!(".{ee}"))); + } + + fn is_hidden_valid(&self, is_hidden: bool) -> bool { + self.has_hidden_items + .load(std::sync::atomic::Ordering::Relaxed) + && is_hidden + } + + fn refresh_items(&self) { + let mut items = self.items.write().unwrap(); + let current_path = self.current_path.read().unwrap(); + + items.clear(); + + // Add parent directory entry if not at root + if let Some(parent) = current_path.parent() { + items.push(FileItem { + name: "..".to_string(), + path: parent.to_path_buf(), + is_directory: true, + is_hidden: false, + size: None, + modified: None, + is_selected: false, + }); + } + + // Add directory contents + if let Ok(entries) = fs::read_dir(current_path.deref()) { + let mut dir_items: Vec = entries + .filter_map(|entry| { + match entry { + Ok(entry) => { + return self.transform_to_item(entry); + } + Err(_) => { + // TODO: info | log exception on TUI + return None; + } + } + }) + .collect(); + + // Sort based on current sort mode + self.sort_items(&mut dir_items); + items.extend(dir_items); + } + } + + fn transform_to_item(&self, entry: DirEntry) -> Option { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_directory = path.is_dir(); + let is_hidden = name.starts_with('.'); + let is_hidden_valid = self.is_hidden_valid(is_hidden); + let is_extension_valid = self.is_extension_valid(&name); + let is_valid = is_hidden_valid && is_extension_valid; + + if is_valid { + return None; + } + + let (size, modified) = if let Ok(metadata) = entry.metadata() { + let size = if metadata.is_file() { + Some(metadata.len()) + } else { + None + }; + let modified = metadata.modified().ok(); + (size, modified) + } else { + (None, None) + }; + + let is_selected = self + .selected_files_in + .read() + .unwrap() + .contains(&path); + + Some(FileItem { + name, + path, + is_directory, + is_hidden, + size, + modified, + is_selected, + }) + } + + fn on_save(&self) { + let selected_files = self.get_selected_files(); + + if selected_files.is_empty() { + return; + } + + if let Some(sub) = self.get_sub() { + sub.on_save(AppFileBrowserSaveEvent { selected_files }); + } + + self.b.get_navigation().go_back(); + } + + fn select_file(&self, item: &mut FileItem) { + if item.is_directory { + return; + } + + let mut selected_files_in = self.selected_files_in.write().unwrap(); + + if item.is_selected { + selected_files_in.retain(|p| p != &item.path); + item.is_selected = false; + } else { + if !selected_files_in.contains(&item.path) { + selected_files_in.push(item.path.clone()); + } + item.is_selected = true; + } + } + + fn select_dir(&self, item: &mut FileItem) { + if !item.is_directory { + return; + } + + self.select_item(item); + } + + fn select_item(&self, item: &mut FileItem) { + let mut selected_files_in = self.selected_files_in.write().unwrap(); + + if item.is_selected { + selected_files_in.retain(|p| p != &item.path); + item.is_selected = false; + } else { + if !selected_files_in.contains(&item.path) { + selected_files_in.push(item.path.clone()); + } + item.is_selected = true; + } + } + + fn has_hidden_items(&self) -> bool { + self.has_hidden_items + .load(std::sync::atomic::Ordering::Relaxed) + } + + fn get_sort(&self) -> SortMode { + self.sort.read().unwrap().clone() + } + + fn get_mode(&self) -> BrowserMode { + self.mode.read().unwrap().clone() + } + + fn draw_header(&self, f: &mut Frame, block: Rect) { + let current_path = self.get_current_path(); + let show_hidden = self.has_hidden_items(); + let sort = self.get_sort(); + let selected_files = self.get_selected_files(); + let mode = self.get_mode(); + + // Header with current path and controls + let header_content = vec![ + Line::from(vec![ + Span::styled("📁 ", Style::default().fg(Color::Blue)), + Span::styled( + "Current Path: ", + Style::default().fg(Color::White).bold(), + ), + Span::styled( + format!("{}", current_path.display()), + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(vec![ + Span::styled("Sort: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{:?}", sort), + Style::default().fg(Color::Yellow), + ), + Span::styled(" • ", Style::default().fg(Color::Gray)), + Span::styled( + if show_hidden { + "Hidden: On" + } else { + "Hidden: Off" + }, + Style::default().fg(Color::Yellow), + ), + Span::styled( + if matches!(mode, BrowserMode::SelectMultiFiles) { + format!(" • Selected: {}", selected_files.len()) + } else { + String::new() + }, + Style::default().fg(Color::Green), + ), + ]), + ]; + + let header_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .title(match mode { + BrowserMode::SelectFile => " File Browser - Select a File ", + BrowserMode::SelectMultiFiles => { + " File Browser - Select Multiple Files " + } + BrowserMode::SelectDirectory => { + " Directory Browser - Select Folder " + } + }) + .title_style(Style::default().fg(Color::White).bold()); + + let header = Paragraph::new(header_content) + .block(header_block) + .alignment(Alignment::Left); + + f.render_widget(header, block); + } + + fn get_items(&self) -> Vec { + self.items.read().unwrap().clone() + } + + fn get_list_items(&self) -> Vec> { + self.get_items() + .iter() + .map(|item| transform_into_list_item(item.clone())) + .collect() + } + + fn draw_file_list(&self, f: &mut Frame, block: Rect) { + let list_items = self.get_list_items(); + + let list_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::White)); + + let list = List::new(list_items) + .block(list_block) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, block, &mut self.menu.write().unwrap()); + } + + fn draw_footer_with_help(&self, f: &mut Frame, block: Rect) { + let footer_content = vec![Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Cyan).bold()), + Span::styled( + ": Enter Directory • ", + Style::default().fg(Color::Gray), + ), + Span::styled("Space", Style::default().fg(Color::Green).bold()), + Span::styled( + ": Select/Deselect • ", + Style::default().fg(Color::Gray), + ), + Span::styled("CTRL-H", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Hidden • ", Style::default().fg(Color::Gray)), + Span::styled("CTRL-J", Style::default().fg(Color::Magenta).bold()), + Span::styled(": Sort • ", Style::default().fg(Color::Gray)), + Span::styled("CTRL-C", Style::default().fg(Color::Magenta).bold()), + Span::styled(": Cancel • ", Style::default().fg(Color::Gray)), + Span::styled("CTRL-S", Style::default().fg(Color::Red).bold()), + Span::styled(": Save", Style::default().fg(Color::Gray)), + ])]; + + let footer_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Gray)) + .title(" Controls ") + .title_style(Style::default().fg(Color::White).bold()); + + let footer = Paragraph::new(footer_content) + .block(footer_block) + .alignment(Alignment::Center); + + f.render_widget(footer, block); + } + + fn get_layout_blocks(&self, area: Rect) -> Rc<[Rect]> { + let blocks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // Header with path and controls + Constraint::Min(0), // File list + Constraint::Length(3), // Footer with help + ]) + .split(area); + + blocks + } + + fn get_sub(&self) -> Option> { + self.sub.read().unwrap().clone() + } +} + +fn transform_into_list_item(item: FileItem) -> ListItem<'static> { + let (icon, color) = if item.name == ".." { + ("⬆️ ", Color::Yellow) + } else if item.is_directory { + ("📁 ", Color::Cyan) + } else { + match item.path.extension().and_then(|s| s.to_str()) { + Some("txt") | Some("md") | Some("rs") => ("📝 ", Color::Green), + Some("jpg") | Some("png") | Some("gif") => ("🖼️ ", Color::Magenta), + Some("mp3") | Some("wav") | Some("flac") => ("🎵 ", Color::Blue), + Some("mp4") | Some("avi") | Some("mkv") => ("🎬 ", Color::Red), + Some("zip") | Some("tar") | Some("gz") => ("📦 ", Color::Yellow), + _ => ("📄 ", Color::White), + } + }; + + let size_str = if let Some(size) = item.size { + format_file_size(size) + } else if item.is_directory && item.name != ".." { + "".to_string() + } else { + String::new() + }; + + let selection_indicator = if item.is_selected { + "✓ " + } else { + " " + }; + + let style = if item.is_selected { + Style::default().fg(Color::Green).bold() + } else if item.is_hidden { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(color) + }; + + let line = if size_str.is_empty() { + format!("{}{}{}", selection_indicator, icon, item.name) + } else { + format!( + "{}{}{} ({})", + selection_indicator, icon, item.name, size_str + ) + }; + + ListItem::new(line).style(style) +} + +fn format_file_size(size: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size_f = size as f64; + let mut unit_idx = 0; + + while size_f >= 1024.0 && unit_idx < UNITS.len() - 1 { + size_f /= 1024.0; + unit_idx += 1; + } + + if unit_idx == 0 { + format!("{} {}", size, UNITS[unit_idx]) + } else { + format!("{:.1} {}", size_f, UNITS[unit_idx]) + } +} diff --git a/drop-core/tui/src/apps/help.rs b/drop-core/tui/src/apps/help.rs new file mode 100644 index 00000000..4ed88f8b --- /dev/null +++ b/drop-core/tui/src/apps/help.rs @@ -0,0 +1,387 @@ +use std::sync::Arc; + +use ratatui::{ + Frame, + crossterm::event::{Event, KeyCode}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + symbols::border, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::{App, AppBackend, ControlCapture}; + +pub struct HelpApp { + b: Arc, +} + +impl App for HelpApp { + fn draw(&self, f: &mut Frame, area: Rect) { + let main_blocks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), // Title + Constraint::Min(0), // Help content in columns + ]) + .split(area); + + let content_blocks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), // Left column + Constraint::Percentage(50), // Right column + ]) + .split(main_blocks[1]); + + draw_title(f, main_blocks[0]); + draw_left_content(f, content_blocks[0]); + draw_right_content(f, content_blocks[1]) + } + + fn handle_control(&self, ev: &Event) -> Option { + if let Event::Key(key) = ev { + match key.code { + KeyCode::Esc => { + self.b.get_navigation().go_back(); + } + _ => return None, + } + + return Some(ControlCapture::new(ev)); + } + + None + } +} + +fn draw_title(f: &mut Frame<'_>, area: Rect) { + let title_content = vec![Line::from(vec![ + Span::styled("❓ ", Style::default().fg(Color::Magenta).bold()), + Span::styled( + "Help & Documentation", + Style::default().fg(Color::White).bold(), + ), + ])]; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Magenta)) + .title(" User Guide ") + .title_style(Style::default().fg(Color::White).bold()); + + let title = Paragraph::new(title_content) + .block(title_block) + .alignment(Alignment::Center); + + f.render_widget(title, area); +} + +fn draw_left_content(f: &mut Frame<'_>, area: Rect) { + let left_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("🧬 ", Style::default().fg(Color::Yellow)), + Span::styled( + "Navigation Controls", + Style::default().fg(Color::Yellow).bold(), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled( + "↑/↓ Tab/Shift+Tab", + Style::default().fg(Color::White).bold(), + ), + Span::styled(": Navigate", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled("Enter", Style::default().fg(Color::White).bold()), + Span::styled(": Select option", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled("Esc", Style::default().fg(Color::White).bold()), + Span::styled(": Go back", Style::default().fg(Color::Gray)), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled("CTRL-Q", Style::default().fg(Color::White).bold()), + Span::styled( + ": Quit application", + Style::default().fg(Color::Gray), + ), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled("H", Style::default().fg(Color::White).bold()), + Span::styled(": Show help", Style::default().fg(Color::Gray)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("📤 ", Style::default().fg(Color::Green)), + Span::styled( + "Sending Files", + Style::default().fg(Color::Green).bold(), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("1. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Select 'Send Files' from menu", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("2. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Enter file paths to add", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("3. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Set display name (optional)", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("4. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Click Send to start transfer", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("5. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Share ticket with receiver", + Style::default().fg(Color::White), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("📥 ", Style::default().fg(Color::Blue)), + Span::styled( + "Receiving Files", + Style::default().fg(Color::Blue).bold(), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("1. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Select 'Receive Files' from menu", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("2. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Enter transfer ticket", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("3. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Enter confirmation code", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("4. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Set output folder (optional)", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("5. ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "Click Receive to start", + Style::default().fg(Color::White), + ), + ]), + ]; + + let left_block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Quick Start Guide ") + .title_style(Style::default().fg(Color::White).bold()); + + let left_panel = Paragraph::new(left_content) + .block(left_block) + .wrap(Wrap { trim: true }) + .alignment(Alignment::Left); + + f.render_widget(left_panel, area); +} + +fn draw_right_content(f: &mut Frame<'_>, area: Rect) { + let right_content = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("⚙️ ", Style::default().fg(Color::Yellow)), + Span::styled( + "Configuration", + Style::default().fg(Color::Yellow).bold(), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled( + "Set default receive directory", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled( + "View current settings", + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("• ", Style::default().fg(Color::Cyan)), + Span::styled( + "Clear saved preferences", + Style::default().fg(Color::White), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("💻 ", Style::default().fg(Color::Magenta)), + Span::styled( + "Command Line Usage", + Style::default().fg(Color::Magenta).bold(), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Send: ", Style::default().fg(Color::Green).bold()), + Span::styled( + "arkdrop send ", + Style::default().fg(Color::Gray).italic(), + ), + ]), + Line::from(vec![ + Span::styled("Receive: ", Style::default().fg(Color::Blue).bold()), + Span::styled( + "arkdrop receive ", + Style::default().fg(Color::Gray).italic(), + ), + ]), + Line::from(vec![ + Span::styled("Config: ", Style::default().fg(Color::Yellow).bold()), + Span::styled( + "arkdrop config