Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33ea69eab | ||
|
|
18ede809e2 | ||
|
|
ee6d5e0960 | ||
|
|
298819fc91 | ||
|
|
3a5d4e7dbd | ||
|
|
b269fdc11d | ||
|
|
986eda94f9 | ||
|
|
7d15397a9b | ||
|
|
3b5a77da83 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:community.chocolatey.org)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:jrsoftware.org)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build artifacts
|
||||
dist_*/
|
||||
pkg_dist_*/
|
||||
*.deb
|
||||
*.rpm
|
||||
*.pkg.tar.zst
|
||||
*.exe
|
||||
*.asc
|
||||
*.sha256
|
||||
|
||||
# Platform tools (downloaded at runtime)
|
||||
src/platform-tools/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!CLAUDE.md
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
|
||||
# Other
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [3.13]
|
||||
python-version: ['3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Multi-platform build + packaging workflow
|
||||
# Thin GitHub Actions wrapper around Prefect + Dagger pipeline.
|
||||
#
|
||||
# Architecture:
|
||||
# - Windows build runs natively on windows-latest (cannot containerize)
|
||||
# - Linux builds run via Dagger containers orchestrated by Prefect
|
||||
# - Signing, release creation, and R2 upload handled by Prefect tasks
|
||||
# - Container runtime: Podman (Dagger connects via Podman socket)
|
||||
#
|
||||
# Local equivalent:
|
||||
# poetry run python -m ci.prefect_flow full \
|
||||
# --gpg-passphrase "$GPG_PASSPHRASE" \
|
||||
# --github-token "$GITHUB_TOKEN"
|
||||
name: Build Multi-Platform Binaries
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: release-workflow
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI_CD: true
|
||||
CI_CD_PAT: ${{ secrets.CI_CD_PAT }}
|
||||
|
||||
jobs:
|
||||
|
||||
# ── Windows Build (native runner — cannot containerize) ──────────────
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: latest
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
|
||||
- name: Ensure Poetry is on PATH
|
||||
shell: pwsh
|
||||
run: |
|
||||
$poetryPath = Join-Path $env:USERPROFILE ".local\bin"
|
||||
Write-Output $poetryPath >> $Env:GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: poetry install
|
||||
|
||||
- name: Build Windows executable
|
||||
run: poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
||||
|
||||
- name: Install Inno Setup 6.7.1
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup --version 6.7.1 -y --no-progress
|
||||
# Refresh PATH so ISCC.exe is available immediately
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
|
||||
- name: Build Windows installer (Inno Setup)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = (poetry version -s).Trim()
|
||||
Write-Output "Building installer for version $version"
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
|
||||
"scripts\windows\android-file-handler-setup.iss" `
|
||||
"/DMyAppVersion=$version"
|
||||
|
||||
- name: Import GPG key
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:GPG_TTY = "not a tty"
|
||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
gpg --list-secret-keys
|
||||
|
||||
- name: Sign and hash Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$passphraseFile = New-TemporaryFile
|
||||
try {
|
||||
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
|
||||
|
||||
# Sign and hash standalone executable
|
||||
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $exePath) { Write-Error "Standalone executable not found"; exit 1 }
|
||||
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
|
||||
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $(Split-Path -Leaf $exePath)" |
|
||||
Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
|
||||
|
||||
# Sign and hash installer
|
||||
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $setupPath) { Write-Error "Installer not found"; exit 1 }
|
||||
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
|
||||
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
|
||||
"$setupHash $(Split-Path -Leaf $setupPath)" |
|
||||
Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $passphraseFile) { Remove-Item $passphraseFile -Force }
|
||||
}
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-binary
|
||||
path: |
|
||||
dist/android-file-handler-windows.exe
|
||||
dist/android-file-handler-windows.exe.asc
|
||||
dist/android-file-handler-windows.sha256
|
||||
dist/android-file-handler-setup.exe
|
||||
dist/android-file-handler-setup.exe.asc
|
||||
dist/android-file-handler-setup.sha256
|
||||
|
||||
# ── Linux Builds (Prefect + Dagger with Podman backend) ─────────────
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: latest
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
|
||||
- name: Install project + CI dependencies
|
||||
run: poetry install --with ci
|
||||
|
||||
- name: Set up Podman
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq podman
|
||||
# Start rootful Podman socket for Dagger compatibility
|
||||
sudo systemctl enable --now podman.socket
|
||||
echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Dagger CLI
|
||||
uses: dagger/dagger-for-github@v7
|
||||
with:
|
||||
verb: version
|
||||
|
||||
- name: Log in to GHCR (Podman)
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" |
|
||||
podman login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Build all Linux distros (Prefect + Dagger)
|
||||
run: poetry run python -m ci.prefect_flow build-linux
|
||||
|
||||
- name: Import GPG key
|
||||
run: |
|
||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
|
||||
- name: Sign Linux artifacts
|
||||
run: |
|
||||
poetry run python -m ci.prefect_flow sign \
|
||||
--gpg-passphrase "${{ secrets.GPG_PASSPHRASE }}"
|
||||
|
||||
- name: Upload Debian package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debian-package
|
||||
path: |
|
||||
dist/android-file-handler_*.deb
|
||||
dist/android-file-handler_*.deb.asc
|
||||
dist/android-file-handler-debian.sha256
|
||||
pkg_dist_debian/**
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: arch-package
|
||||
path: |
|
||||
dist/*.pkg.tar.*
|
||||
dist/android-file-handler-arch.sha256
|
||||
pkg_dist_arch/**
|
||||
|
||||
- name: Upload RHEL package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rhel-package
|
||||
path: |
|
||||
dist/*.rpm
|
||||
dist/*.rpm.asc
|
||||
dist/android-file-handler-rhel.sha256
|
||||
pkg_dist_rhel/**
|
||||
|
||||
# ── Release + R2 Upload (Prefect) ───────────────────────────────────
|
||||
do-release:
|
||||
needs: [build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: latest
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
|
||||
- name: Install project + CI dependencies
|
||||
run: poetry install --with ci
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: ./dist
|
||||
|
||||
- name: Create GitHub release (Prefect)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
poetry run python -m ci.prefect_flow release \
|
||||
--github-token "$GITHUB_TOKEN"
|
||||
|
||||
upload-r2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: do-release
|
||||
if: needs.do-release.result == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: latest
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
|
||||
- name: Install project + CI dependencies
|
||||
run: poetry install --with ci
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: ./release-files
|
||||
|
||||
- name: Authenticate to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Upload artifacts to Cloudflare R2
|
||||
run: |
|
||||
poetry run python -m ci.prefect_flow upload-r2 \
|
||||
--gcp-project-id "${{ secrets.GCP_PROJECT_ID }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--release-dir "./release-files"
|
||||
|
||||
sync-wiki:
|
||||
needs: do-release
|
||||
if: needs.do-release.result == 'success'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/sync-wiki.yml
|
||||
with:
|
||||
branch: main
|
||||
secrets: inherit
|
||||
+42
-154
@@ -32,15 +32,7 @@ env:
|
||||
|
||||
jobs:
|
||||
|
||||
status-checks:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
uses: ./.github/workflows/status-checks.yml
|
||||
|
||||
build-windows:
|
||||
needs: status-checks
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -76,6 +68,15 @@ jobs:
|
||||
# Use the Windows spec file so packaging is consistent and reproducible
|
||||
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
||||
|
||||
- name: Build Windows installer (Inno Setup)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = (poetry version -s).Trim()
|
||||
Write-Output "Building installer for version $version"
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
|
||||
"scripts\windows\android-file-handler-setup.iss" `
|
||||
"/DMyAppVersion=$version"
|
||||
|
||||
- name: Import GPG key
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -83,23 +84,37 @@ jobs:
|
||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
gpg --list-secret-keys
|
||||
|
||||
- name: Sign and hash Windows executable
|
||||
- name: Sign and hash Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler.exe" -Recurse | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $exePath) {
|
||||
Write-Error "Executable not found"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "Found executable: $exePath"
|
||||
|
||||
# Create temporary file for passphrase
|
||||
$passphraseFile = New-TemporaryFile
|
||||
try {
|
||||
"${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline
|
||||
|
||||
# Sign with GPG using passphrase file
|
||||
# Sign and hash the standalone executable
|
||||
$exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $exePath) {
|
||||
Write-Error "Standalone executable not found"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "Signing executable: $exePath"
|
||||
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath"
|
||||
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline
|
||||
Write-Output "Executable SHA-256: $hash"
|
||||
|
||||
# Sign and hash the Inno Setup installer
|
||||
$setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $setupPath) {
|
||||
Write-Error "Installer not found"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "Signing installer: $setupPath"
|
||||
gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath"
|
||||
$setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower()
|
||||
"$setupHash $(Split-Path -Leaf $setupPath)" | Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline
|
||||
Write-Output "Installer SHA-256: $setupHash"
|
||||
}
|
||||
finally {
|
||||
# Clean up passphrase file
|
||||
@@ -108,25 +123,19 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Generate SHA-256 hash
|
||||
$hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower()
|
||||
$hashFile = "dist/android-file-handler-windows.sha256"
|
||||
"$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath $hashFile -Encoding ASCII -NoNewline
|
||||
Write-Output "SHA-256: $hash"
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-binary
|
||||
path: |
|
||||
dist/**/android-file-handler*.exe
|
||||
dist/**/android-file-handler*.exe.asc
|
||||
dist/android-file-handler.exe
|
||||
dist/android-file-handler.exe.asc
|
||||
dist/android-file-handler-windows.exe
|
||||
dist/android-file-handler-windows.exe.asc
|
||||
dist/android-file-handler-windows.sha256
|
||||
dist/android-file-handler-setup.exe
|
||||
dist/android-file-handler-setup.exe.asc
|
||||
dist/android-file-handler-setup.sha256
|
||||
|
||||
build-debian:
|
||||
needs: status-checks
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -144,50 +153,13 @@ jobs:
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Build executable
|
||||
- name: Build and package Debian
|
||||
run: |
|
||||
# Container has Poetry and all dependencies pre-installed
|
||||
# Python build script handles both PyInstaller build and fpm packaging
|
||||
poetry install --no-interaction
|
||||
poetry run python scripts/build_package_linux.py
|
||||
|
||||
- name: Package .deb (fpm)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! VERSION="$(poetry version -s 2>&1)"; then
|
||||
echo "::error::Failed to read version from pyproject.toml"
|
||||
echo "::error::Poetry output: $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::Version is empty in pyproject.toml"
|
||||
exit 1
|
||||
fi
|
||||
# Validate semver format
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$ ]]; then
|
||||
echo "::error::Invalid version format in pyproject.toml: $VERSION"
|
||||
echo "::error::Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using version: $VERSION"
|
||||
PKG_DIR="pkg_dist_debian"
|
||||
mkdir -p dist
|
||||
echo "Packaging from $PKG_DIR"
|
||||
ls -la "$PKG_DIR" || true
|
||||
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
|
||||
PKG_ITEMS=( "usr/local/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
|
||||
else
|
||||
echo "Note: icon not present, packaging without icon"
|
||||
fi
|
||||
|
||||
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
|
||||
--architecture amd64 --deb-user root --deb-group root \
|
||||
--after-install scripts/debian_postinst.sh \
|
||||
-p "dist/android-file-handler_${VERSION}_amd64.deb" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
|
||||
|
||||
- name: Import GPG key
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -234,7 +206,6 @@ jobs:
|
||||
pkg_dist_debian/**
|
||||
|
||||
build-arch:
|
||||
needs: status-checks
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -257,7 +228,7 @@ jobs:
|
||||
- name: Pull Docker image
|
||||
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
|
||||
|
||||
- name: Build executable inside container
|
||||
- name: Build and package Arch
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v ${{ github.workspace }}:/workspace \
|
||||
@@ -268,52 +239,6 @@ jobs:
|
||||
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
|
||||
sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
- name: Package pacman (fpm) inside container
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v ${{ github.workspace }}:/workspace \
|
||||
-w /workspace \
|
||||
-e FPM_VERSION=${{ env.FPM_VERSION }} \
|
||||
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
|
||||
sh -c 'set -euo pipefail && \
|
||||
if ! VERSION="$(poetry version -s 2>&1)"; then \
|
||||
echo "::error::Failed to read version from pyproject.toml" >&2 && \
|
||||
echo "::error::Poetry output: $VERSION" >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
if [ -z "$VERSION" ]; then \
|
||||
echo "::error::Version is empty in pyproject.toml" >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$ ]]; then \
|
||||
echo "::error::Invalid version format in pyproject.toml: $VERSION" >&2 && \
|
||||
echo "::error::Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)" >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
echo "Using version: $VERSION" && \
|
||||
PKG_DIR="pkg_dist_arch" && \
|
||||
mkdir -p dist && \
|
||||
echo "Packaging from $PKG_DIR" && \
|
||||
ls -la "$PKG_DIR" || true && \
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png" && \
|
||||
if [ -f "$ICON_PATH" ]; then \
|
||||
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
|
||||
--architecture x86_64 \
|
||||
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
|
||||
-C "$PKG_DIR" \
|
||||
"usr/bin/android-file-handler" \
|
||||
"usr/share/applications/android-file-handler.desktop" \
|
||||
"usr/share/icons/hicolor/256x256/apps/android-file-handler.png"; \
|
||||
else \
|
||||
echo "Note: icon not present, packaging without icon" && \
|
||||
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
|
||||
--architecture x86_64 \
|
||||
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
|
||||
-C "$PKG_DIR" \
|
||||
"usr/bin/android-file-handler" \
|
||||
"usr/share/applications/android-file-handler.desktop"; \
|
||||
fi'
|
||||
|
||||
- name: Import GPG key
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -360,7 +285,6 @@ jobs:
|
||||
|
||||
|
||||
build-rhel:
|
||||
needs: status-checks
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
@@ -378,48 +302,13 @@ jobs:
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Build RHEL package
|
||||
- name: Build and package RHEL
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Container has Poetry and all dependencies pre-installed
|
||||
# Python build script handles both PyInstaller build and fpm packaging
|
||||
poetry install --no-interaction
|
||||
poetry run python scripts/build_package_linux.py
|
||||
|
||||
- name: Package RHEL (fpm)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! VERSION="$(poetry version -s 2>&1)"; then
|
||||
echo "::error::Failed to read version from pyproject.toml"
|
||||
echo "::error::Poetry output: $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::Version is empty in pyproject.toml"
|
||||
exit 1
|
||||
fi
|
||||
# Validate semver format
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$ ]]; then
|
||||
echo "::error::Invalid version format in pyproject.toml: $VERSION"
|
||||
echo "::error::Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using version: $VERSION"
|
||||
PKG_DIR="pkg_dist_rhel"
|
||||
mkdir -p dist
|
||||
echo "Packaging from $PKG_DIR (version=$VERSION)"
|
||||
ls -la "$PKG_DIR" || true
|
||||
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
|
||||
PKG_ITEMS=( "usr/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
|
||||
else
|
||||
echo "Note: icon not present, packaging without icon"
|
||||
fi
|
||||
|
||||
fpm -s dir -t rpm -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/bin --after-install scripts/rhel_postinst.sh -p "dist/android-file-handler-${VERSION}.x86_64.rpm" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
|
||||
|
||||
- name: Import GPG key
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -468,7 +357,6 @@ jobs:
|
||||
|
||||
do-release:
|
||||
needs:
|
||||
- status-checks
|
||||
- build-windows
|
||||
- build-debian
|
||||
- build-arch
|
||||
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest tests/ -v
|
||||
|
||||
build-windows:
|
||||
build-and-package-windows:
|
||||
needs: [run-unit-tests-windows]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
dist/**/android-file-handler*.exe
|
||||
dist/android-file-handler.exe
|
||||
|
||||
build-debian:
|
||||
build-and-package-debian:
|
||||
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -172,35 +172,13 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build executable
|
||||
- name: Build and package Debian
|
||||
run: |
|
||||
# Container has Poetry and all dependencies pre-installed
|
||||
# Python build script handles both PyInstaller build and fpm packaging
|
||||
poetry install --no-interaction
|
||||
poetry run python scripts/build_package_linux.py
|
||||
|
||||
- name: Package .deb (fpm)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(poetry version -s)"
|
||||
PKG_DIR="pkg_dist_debian"
|
||||
mkdir -p dist
|
||||
echo "Packaging from $PKG_DIR"
|
||||
ls -la "$PKG_DIR" || true
|
||||
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
|
||||
PKG_ITEMS=( "usr/local/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
|
||||
else
|
||||
echo "Note: icon not present, packaging without icon"
|
||||
fi
|
||||
|
||||
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
|
||||
--architecture amd64 --deb-user root --deb-group root \
|
||||
--after-install scripts/debian_postinst.sh \
|
||||
-p "dist/android-file-handler_${VERSION}_amd64.deb" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
|
||||
|
||||
- name: Upload Debian .deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -209,7 +187,7 @@ jobs:
|
||||
dist/android-file-handler_*.deb
|
||||
pkg_dist_debian/**
|
||||
|
||||
build-arch:
|
||||
build-and-package-arch:
|
||||
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -219,20 +197,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.CI_CD_PAT }} # Personal Access Token with read:packages
|
||||
|
||||
password: ${{ secrets.CI_CD_PAT }}
|
||||
|
||||
- name: Pull Docker image
|
||||
run: docker pull ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
|
||||
|
||||
- name: Build executable inside container
|
||||
- name: Build and package Arch
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v ${{ github.workspace }}:/workspace \
|
||||
@@ -243,38 +220,6 @@ jobs:
|
||||
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
|
||||
sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
- name: Package pacman (fpm) inside container
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v ${{ github.workspace }}:/workspace \
|
||||
-w /workspace \
|
||||
-e FPM_VERSION=${{ env.FPM_VERSION }} \
|
||||
ghcr.io/jmr-dev/android-file-handler-arch-builder:latest \
|
||||
sh -c 'set -euo pipefail && \
|
||||
VERSION="$(poetry version -s)" && \
|
||||
PKG_DIR="pkg_dist_arch" && \
|
||||
mkdir -p dist && \
|
||||
echo "Packaging from $PKG_DIR" && \
|
||||
ls -la "$PKG_DIR" || true && \
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png" && \
|
||||
if [ -f "$ICON_PATH" ]; then \
|
||||
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
|
||||
--architecture x86_64 \
|
||||
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
|
||||
-C "$PKG_DIR" \
|
||||
"usr/bin/android-file-handler" \
|
||||
"usr/share/applications/android-file-handler.desktop" \
|
||||
"usr/share/icons/hicolor/256x256/apps/android-file-handler.png"; \
|
||||
else \
|
||||
echo "Note: icon not present, packaging without icon" && \
|
||||
fpm -s dir -t pacman -n android-file-handler -v "$VERSION" \
|
||||
--architecture x86_64 \
|
||||
-p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" \
|
||||
-C "$PKG_DIR" \
|
||||
"usr/bin/android-file-handler" \
|
||||
"usr/share/applications/android-file-handler.desktop"; \
|
||||
fi'
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -283,7 +228,7 @@ jobs:
|
||||
dist/*.pkg.tar.*
|
||||
pkg_dist_arch/**
|
||||
|
||||
build-rhel:
|
||||
build-and-package-rhel:
|
||||
needs: [run-unit-tests-debian, run-unit-tests-arch, run-unit-tests-rhel]
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -300,33 +245,13 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build RHEL package
|
||||
- name: Build and package RHEL
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Container has Poetry and all dependencies pre-installed
|
||||
# Python build script handles both PyInstaller build and fpm packaging
|
||||
poetry install --no-interaction
|
||||
poetry run python scripts/build_package_linux.py
|
||||
|
||||
- name: Package RHEL (fpm)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(poetry version -s)"
|
||||
PKG_DIR="pkg_dist_rhel"
|
||||
mkdir -p dist
|
||||
echo "Packaging from $PKG_DIR (version=$VERSION)"
|
||||
ls -la "$PKG_DIR" || true
|
||||
|
||||
ICON_PATH="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
|
||||
PKG_ITEMS=( "usr/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
|
||||
else
|
||||
echo "Note: icon not present, packaging without icon"
|
||||
fi
|
||||
|
||||
fpm -s dir -t rpm -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/bin --after-install scripts/rhel_postinst.sh -p "dist/android-file-handler-${VERSION}.x86_64.rpm" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
|
||||
|
||||
- name: Upload RHEL artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
.venv/
|
||||
platform-tools**
|
||||
dist*
|
||||
build_temp/
|
||||
@@ -14,4 +15,6 @@ pkg*
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*$py.class
|
||||
.vagrant/
|
||||
vagrant_test_results/
|
||||
@@ -54,14 +54,56 @@ poetry run mypy src/
|
||||
|
||||
#### Local Development Build (Linux)
|
||||
```sh
|
||||
# Interactive build (prompts for distro selection)
|
||||
poetry run python scripts/build_package_linux.py
|
||||
```
|
||||
|
||||
#### Prefect + Dagger Build (CI Pipeline Locally)
|
||||
```sh
|
||||
# Install CI dependencies
|
||||
poetry install --with ci
|
||||
|
||||
# Build all Linux distros via Dagger containers
|
||||
poetry run python -m ci.prefect_flow build-linux
|
||||
|
||||
# Sign artifacts
|
||||
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
|
||||
|
||||
# Full pipeline (build + sign + release)
|
||||
poetry run python -m ci.prefect_flow full \
|
||||
--gpg-passphrase "$GPG_PASSPHRASE" \
|
||||
--github-token "$GITHUB_TOKEN"
|
||||
|
||||
# Upload artifacts to Cloudflare R2 (standalone)
|
||||
poetry run python -m ci.prefect_flow upload-r2 \
|
||||
--gcp-project-id "$GCP_PROJECT_ID"
|
||||
```
|
||||
|
||||
#### Podman Compose Build (Recommended for Linux)
|
||||
```sh
|
||||
# Build all distributions
|
||||
podman-compose up --build
|
||||
|
||||
# Build specific distribution
|
||||
podman-compose up --build debian
|
||||
podman-compose up --build arch
|
||||
podman-compose up --build rhel
|
||||
|
||||
# Clean build artifacts
|
||||
podman-compose down -v && rm -rf dist pkg_dist_* dist_*
|
||||
```
|
||||
|
||||
See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation.
|
||||
|
||||
#### Platform-Specific Builds
|
||||
```sh
|
||||
# Windows executable (PyInstaller)
|
||||
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
||||
|
||||
# Windows installer (Inno Setup, after PyInstaller build)
|
||||
# Inno Setup 6.7.1 is installed by the CI/CD workflow via Chocolatey (pinned version)
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1
|
||||
|
||||
# Linux packages use distro-specific spec files:
|
||||
# - android-file-handler-debian.spec
|
||||
# - android-file-handler-arch.spec
|
||||
@@ -96,6 +138,13 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
||||
- **scripts/**: Build and packaging scripts
|
||||
- `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var)
|
||||
- `spec_scripts/`: PyInstaller spec files for each platform
|
||||
- `windows/android-file-handler-setup.iss`: Inno Setup installer configuration
|
||||
|
||||
- **ci/**: CI/CD pipeline orchestration
|
||||
- `config.py`: Shared build configuration (distro configs, image references)
|
||||
- `dagger_pipeline.py`: Dagger container build definitions for Linux
|
||||
- `prefect_flow.py`: Prefect flow orchestration and CLI entry point
|
||||
- `signing.py`: GPG signing and SHA-256 hashing utilities
|
||||
|
||||
- **tests/**: Test suite mirroring src/ structure
|
||||
|
||||
@@ -115,12 +164,26 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
|
||||
|
||||
## CI/CD
|
||||
|
||||
The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`):
|
||||
- Runs tests on Linux and Windows
|
||||
- Builds binaries for Windows, Debian, Arch, and RHEL
|
||||
- Packages using PyInstaller + fpm
|
||||
- Supports manual workflow dispatch with configurable jobs
|
||||
- Optional GitHub release creation and S3 upload
|
||||
The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`):
|
||||
|
||||
- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images
|
||||
- **Prefect** orchestrates the pipeline: build → sign → release → R2 upload
|
||||
- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize)
|
||||
- **Podman** is the container runtime (Dagger connects via Podman socket)
|
||||
|
||||
Pipeline structure:
|
||||
1. `build-windows` — Native Windows build on `windows-latest`
|
||||
2. `build-linux` — All Linux distros built in parallel via Prefect + Dagger
|
||||
3. `do-release` — Creates GitHub release with all artifacts
|
||||
4. `upload-r2` — Uploads artifacts to Cloudflare R2 (credentials from GCP Secrets Manager)
|
||||
5. `sync-wiki` — Wiki synchronization
|
||||
|
||||
The CI pipeline modules live in `ci/`:
|
||||
- `ci/config.py` — Shared build configuration
|
||||
- `ci/dagger_pipeline.py` — Dagger container build definitions
|
||||
- `ci/prefect_flow.py` — Prefect flow orchestration and CLI
|
||||
- `ci/r2_upload.py` — Cloudflare R2 upload with GCP Secrets Manager integration
|
||||
- `ci/signing.py` — GPG signing and SHA-256 hashing utilities
|
||||
|
||||
## Coding Standards
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CI/CD pipeline orchestration using Prefect and Dagger."""
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Shared configuration for CI/CD pipeline."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonBuildConfig:
|
||||
"""Configuration for building Python from source."""
|
||||
|
||||
version: str = "3.14.3"
|
||||
source_url: str = "https://www.python.org/ftp/python/3.14.3/Python-3.14.3.tgz"
|
||||
sha256: str = "d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DistroConfig:
|
||||
"""Configuration for a Linux distribution build."""
|
||||
|
||||
name: str
|
||||
distro_type: str
|
||||
container_image: str
|
||||
bin_path: str
|
||||
pkg_type: str
|
||||
architecture: str
|
||||
postinstall: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelineConfig:
|
||||
"""Top-level pipeline configuration."""
|
||||
|
||||
fpm_version: str = "1.16.0"
|
||||
project_root: Path = field(
|
||||
default_factory=lambda: Path(__file__).parent.parent.resolve()
|
||||
)
|
||||
python_build: PythonBuildConfig = field(default_factory=PythonBuildConfig)
|
||||
|
||||
# Base container images (Python and tools are installed by the pipeline)
|
||||
debian_image: str = "debian:13"
|
||||
arch_image: str = "archlinux:latest"
|
||||
rhel_image: str = "fedora:42"
|
||||
|
||||
@property
|
||||
def distros(self) -> list[DistroConfig]:
|
||||
"""Return all Linux distribution build configurations."""
|
||||
return [
|
||||
DistroConfig(
|
||||
name="Debian",
|
||||
distro_type="debian",
|
||||
container_image=self.debian_image,
|
||||
bin_path="usr/local/bin",
|
||||
pkg_type="deb",
|
||||
architecture="amd64",
|
||||
postinstall="scripts/debian_postinst.sh",
|
||||
),
|
||||
DistroConfig(
|
||||
name="Arch",
|
||||
distro_type="arch",
|
||||
container_image=self.arch_image,
|
||||
bin_path="usr/bin",
|
||||
pkg_type="pacman",
|
||||
architecture="x86_64",
|
||||
postinstall=None,
|
||||
),
|
||||
DistroConfig(
|
||||
name="RHEL",
|
||||
distro_type="rhel",
|
||||
container_image=self.rhel_image,
|
||||
bin_path="usr/bin",
|
||||
pkg_type="rpm",
|
||||
architecture="x86_64",
|
||||
postinstall="scripts/rhel_postinst.sh",
|
||||
),
|
||||
]
|
||||
|
||||
# Artifact patterns for each distro
|
||||
artifact_patterns: dict[str, list[str]] = field(
|
||||
default_factory=lambda: {
|
||||
"debian": ["dist/android-file-handler_*.deb"],
|
||||
"arch": ["dist/android-file-handler-*.pkg.tar.zst"],
|
||||
"rhel": ["dist/android-file-handler-*.rpm"],
|
||||
"windows": [
|
||||
"dist/android-file-handler-windows.exe",
|
||||
"dist/android-file-handler-setup.exe",
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Dagger pipeline for building Linux distribution packages.
|
||||
|
||||
Uses the Dagger Python SDK to run containerized builds for each
|
||||
Linux distribution (Debian, Arch, RHEL) starting from base OS images,
|
||||
downloading and compiling Python from source with SHA256 verification,
|
||||
and building the application packages.
|
||||
"""
|
||||
|
||||
# pyright: reportUnknownMemberType=false
|
||||
# pyright: reportUnknownVariableType=false
|
||||
# pyright: reportUnknownArgumentType=false
|
||||
# pyright: reportUnknownParameterType=false
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import dagger # type: ignore[import-not-found]
|
||||
|
||||
from ci.config import DistroConfig, PipelineConfig
|
||||
|
||||
|
||||
def _get_registry_token() -> str | None:
|
||||
"""Read GHCR token from GITHUB_TOKEN or GHCR_TOKEN environment variable."""
|
||||
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GHCR_TOKEN")
|
||||
|
||||
|
||||
def _get_system_deps_cmd(distro_type: str) -> list[str]:
|
||||
"""Get the shell command to install system build dependencies.
|
||||
|
||||
Args:
|
||||
distro_type: One of 'debian', 'arch', 'rhel'.
|
||||
|
||||
Returns:
|
||||
Shell command as list for with_exec.
|
||||
"""
|
||||
if distro_type == "debian":
|
||||
return [
|
||||
"sh",
|
||||
"-c",
|
||||
"apt-get update && apt-get install -y --no-install-recommends "
|
||||
"curl git build-essential ruby ruby-dev gcc make "
|
||||
"zlib1g-dev ca-certificates tcl-dev tk-dev "
|
||||
"libx11-6 libxext6 libxrender1 libxcb1 "
|
||||
"libbz2-dev libreadline-dev libsqlite3-dev libssl-dev libffi-dev "
|
||||
"wget tar liblzma-dev patch && "
|
||||
"apt-get clean && rm -rf /var/lib/apt/lists/*",
|
||||
]
|
||||
elif distro_type == "arch":
|
||||
return [
|
||||
"sh",
|
||||
"-c",
|
||||
"pacman -Syu --noconfirm "
|
||||
"ruby ruby-bundler ruby-rake base-devel curl git tar "
|
||||
"ca-certificates ca-certificates-utils "
|
||||
"tk tcl libx11 libxext libxrender libxcb "
|
||||
"gcc make zlib bzip2 readline sqlite openssl libffi "
|
||||
"wget xz patch && "
|
||||
"update-ca-trust && pacman -Scc --noconfirm",
|
||||
]
|
||||
elif distro_type == "rhel":
|
||||
return [
|
||||
"sh",
|
||||
"-c",
|
||||
"dnf -y update && dnf -y install "
|
||||
"gcc make zlib-devel bzip2 bzip2-devel readline-devel "
|
||||
"sqlite-devel openssl-devel libffi-devel wget tar git curl "
|
||||
"ruby rubygems rpm-build redhat-rpm-config gcc-c++ patch which "
|
||||
"xz-devel tk-devel tcl-devel libX11-devel libXext-devel "
|
||||
"libXrender-devel && dnf clean all",
|
||||
]
|
||||
raise ValueError(f"Unknown distro type: {distro_type}")
|
||||
|
||||
|
||||
def _get_python_configure_env(distro_type: str) -> str:
|
||||
"""Get distro-specific LDFLAGS and CPPFLAGS for Python configure.
|
||||
|
||||
Args:
|
||||
distro_type: One of 'debian', 'arch', 'rhel'.
|
||||
|
||||
Returns:
|
||||
String with environment variable exports for the configure step.
|
||||
"""
|
||||
if distro_type == "debian":
|
||||
return 'LDFLAGS="-L/usr/lib/x86_64-linux-gnu" CPPFLAGS="-I/usr/include/tcl8.6"'
|
||||
elif distro_type == "arch":
|
||||
return 'LDFLAGS="-L/usr/lib" CPPFLAGS="-I/usr/include"'
|
||||
elif distro_type == "rhel":
|
||||
return 'LDFLAGS="-L/usr/lib64" CPPFLAGS="-I/usr/include"'
|
||||
return ""
|
||||
|
||||
|
||||
def _install_fpm(
|
||||
container: dagger.Container,
|
||||
distro_type: str,
|
||||
fpm_version: str,
|
||||
) -> dagger.Container:
|
||||
"""Install fpm (Effing Package Management) in the container.
|
||||
|
||||
Args:
|
||||
container: Dagger container to install fpm in.
|
||||
distro_type: One of 'debian', 'arch', 'rhel'.
|
||||
fpm_version: Version of fpm to install.
|
||||
|
||||
Returns:
|
||||
Container with fpm installed.
|
||||
"""
|
||||
if distro_type == "arch":
|
||||
container = container.with_exec(
|
||||
["gem", "install", "--no-document", "erb"]
|
||||
).with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f'gem install --no-document -v "{fpm_version}" fpm && '
|
||||
"GEM_BIN_DIR=$(ruby -e 'puts Gem.user_dir')/bin && "
|
||||
'ln -sf "${GEM_BIN_DIR}/fpm" /usr/local/bin/fpm',
|
||||
]
|
||||
)
|
||||
else:
|
||||
container = container.with_exec(
|
||||
["gem", "install", "--no-document", "-v", fpm_version, "fpm"]
|
||||
)
|
||||
return container
|
||||
|
||||
|
||||
async def build_linux_distro(
|
||||
client: dagger.Client,
|
||||
config: PipelineConfig,
|
||||
distro: DistroConfig,
|
||||
registry_token: str | None = None,
|
||||
) -> dict[str, Path]:
|
||||
"""Build a single Linux distribution package inside a Dagger container.
|
||||
|
||||
Starts from a base OS image, compiles Python from source with SHA256
|
||||
verification, installs build tools (Poetry, fpm), and builds the package.
|
||||
|
||||
Args:
|
||||
client: Active Dagger client connection.
|
||||
config: Pipeline configuration.
|
||||
distro: Distribution-specific build configuration.
|
||||
registry_token: Optional registry auth token.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping artifact names to their local output paths.
|
||||
"""
|
||||
print(f"[dagger] Starting {distro.name} build using {distro.container_image}")
|
||||
|
||||
source = client.host().directory(
|
||||
str(config.project_root),
|
||||
exclude=[".venv", "__pycache__", "dist", "dist_*", "pkg_dist_*", ".git"],
|
||||
)
|
||||
|
||||
python = config.python_build
|
||||
configure_env = _get_python_configure_env(distro.distro_type)
|
||||
|
||||
base = client.container()
|
||||
if registry_token:
|
||||
secret = client.set_secret("ghcr_token", registry_token)
|
||||
base = base.with_registry_auth("ghcr.io", "_token", secret)
|
||||
|
||||
# Start from base image and install system dependencies
|
||||
container = base.from_(distro.container_image).with_exec(
|
||||
_get_system_deps_cmd(distro.distro_type)
|
||||
)
|
||||
|
||||
# Download Python source and verify SHA256 against python.org
|
||||
print(f"[dagger] Downloading Python {python.version} and verifying SHA256")
|
||||
container = container.with_exec(
|
||||
["wget", "-q", python.source_url, "-O", f"/tmp/Python-{python.version}.tgz"]
|
||||
).with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f'echo "{python.sha256} /tmp/Python-{python.version}.tgz" '
|
||||
f"| sha256sum -c -",
|
||||
]
|
||||
)
|
||||
|
||||
# Build and install Python from source
|
||||
print(f"[dagger] Compiling Python {python.version} from source")
|
||||
container = (
|
||||
container.with_exec(
|
||||
["tar", "xzf", f"/tmp/Python-{python.version}.tgz", "-C", "/tmp"]
|
||||
)
|
||||
.with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f"cd /tmp/Python-{python.version} && "
|
||||
f"{configure_env} ./configure --enable-shared "
|
||||
f"--with-ensurepip=install --prefix=/usr/local && "
|
||||
f"make -j$(nproc) && "
|
||||
f"make install",
|
||||
]
|
||||
)
|
||||
.with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
'echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && ldconfig',
|
||||
]
|
||||
)
|
||||
.with_exec(["ln", "-sf", "/usr/local/bin/python3", "/usr/local/bin/python"])
|
||||
.with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f"rm -rf /tmp/Python-{python.version} "
|
||||
f"/tmp/Python-{python.version}.tgz",
|
||||
]
|
||||
)
|
||||
.with_exec(
|
||||
[
|
||||
"python3",
|
||||
"-c",
|
||||
"import tkinter; import _tkinter; print('tkinter support verified')",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Install Poetry
|
||||
container = (
|
||||
container.with_exec(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"curl -sSL https://install.python-poetry.org | python3 - --yes",
|
||||
]
|
||||
)
|
||||
.with_env_variable(
|
||||
"PATH", "/root/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
)
|
||||
.with_exec(["poetry", "--version"])
|
||||
)
|
||||
|
||||
# Install fpm
|
||||
container = _install_fpm(container, distro.distro_type, config.fpm_version)
|
||||
|
||||
# Mount workspace and run build
|
||||
container = (
|
||||
container.with_directory("/workspace", source)
|
||||
.with_workdir("/workspace")
|
||||
.with_env_variable("CI_CD", "true")
|
||||
.with_env_variable("DISTRO_TYPE", distro.distro_type)
|
||||
.with_env_variable("FPM_VERSION", config.fpm_version)
|
||||
.with_env_variable("POETRY_VIRTUALENVS_IN_PROJECT", "false")
|
||||
.with_env_variable("POETRY_VIRTUALENVS_PATH", "/tmp/poetry-cache")
|
||||
.with_exec(["poetry", "install", "--no-interaction"])
|
||||
.with_exec(["poetry", "run", "python", "scripts/build_package_linux.py"])
|
||||
)
|
||||
|
||||
# Export build artifacts back to host
|
||||
dist_output = config.project_root / "dist"
|
||||
pkg_dist_output = config.project_root / f"pkg_dist_{distro.distro_type}"
|
||||
|
||||
await container.directory("/workspace/dist").export(str(dist_output))
|
||||
await container.directory(f"/workspace/pkg_dist_{distro.distro_type}").export(
|
||||
str(pkg_dist_output)
|
||||
)
|
||||
|
||||
print(
|
||||
f"[dagger] {distro.name} build complete — artifacts exported to {dist_output}"
|
||||
)
|
||||
|
||||
return {
|
||||
"dist": dist_output,
|
||||
"pkg_dist": pkg_dist_output,
|
||||
}
|
||||
|
||||
|
||||
async def build_all_linux(
|
||||
config: PipelineConfig | None = None,
|
||||
) -> dict[str, dict[str, Path]]:
|
||||
"""Build all Linux distribution packages in parallel via Dagger.
|
||||
|
||||
Args:
|
||||
config: Pipeline configuration. Uses defaults if not provided.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping distro names to their artifact paths.
|
||||
"""
|
||||
if config is None:
|
||||
config = PipelineConfig()
|
||||
|
||||
results: dict[str, dict[str, Path]] = {}
|
||||
|
||||
token = _get_registry_token()
|
||||
|
||||
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
|
||||
tasks = {
|
||||
distro.distro_type: build_linux_distro(
|
||||
client, config, distro, registry_token=token
|
||||
)
|
||||
for distro in config.distros
|
||||
}
|
||||
|
||||
# Run all distro builds concurrently
|
||||
completed = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
|
||||
for distro_type, result in zip(tasks.keys(), completed):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[dagger] ERROR: {distro_type} build failed: {result}")
|
||||
raise result
|
||||
results[distro_type] = result # type: ignore[assignment]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def build_single_linux(
|
||||
distro_type: str, config: PipelineConfig | None = None
|
||||
) -> dict[str, Path]:
|
||||
"""Build a single Linux distribution package.
|
||||
|
||||
Args:
|
||||
distro_type: One of 'debian', 'arch', 'rhel'.
|
||||
config: Pipeline configuration. Uses defaults if not provided.
|
||||
|
||||
Returns:
|
||||
Dictionary of artifact paths for the built distro.
|
||||
"""
|
||||
if config is None:
|
||||
config = PipelineConfig()
|
||||
|
||||
distro = next((d for d in config.distros if d.distro_type == distro_type), None)
|
||||
if distro is None:
|
||||
raise ValueError(
|
||||
f"Unknown distro type '{distro_type}'. Valid: debian, arch, rhel"
|
||||
)
|
||||
|
||||
token = _get_registry_token()
|
||||
|
||||
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
|
||||
return await build_linux_distro(client, config, distro, registry_token=token)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(build_all_linux())
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Prefect orchestration flow for the CI/CD release pipeline.
|
||||
|
||||
Coordinates Dagger-based Linux builds, GPG signing, GitHub release
|
||||
creation, and Cloudflare R2 artifact upload.
|
||||
|
||||
Usage:
|
||||
# Build all Linux distros (CI)
|
||||
poetry run python -m ci.prefect_flow build-linux
|
||||
|
||||
# Sign artifacts in dist/
|
||||
poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE"
|
||||
|
||||
# Create GitHub release + upload R2
|
||||
poetry run python -m ci.prefect_flow release --github-token "$GITHUB_TOKEN"
|
||||
|
||||
# Full pipeline (build + sign + release + R2)
|
||||
poetry run python -m ci.prefect_flow full --gpg-passphrase "$GPG_PASSPHRASE" \\
|
||||
--github-token "$GITHUB_TOKEN"
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from prefect import flow, task
|
||||
|
||||
from ci.config import PipelineConfig
|
||||
from ci.dagger_pipeline import build_all_linux
|
||||
from ci.r2_upload import upload_to_r2
|
||||
from ci.signing import sign_and_hash
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@task(name="build-linux-distros", retries=1, retry_delay_seconds=30)
|
||||
def task_build_linux(config: PipelineConfig) -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
|
||||
"""Build all Linux distribution packages via Dagger containers."""
|
||||
print("=== Building Linux packages via Dagger ===")
|
||||
results = asyncio.run(build_all_linux(config))
|
||||
print(f"Linux builds completed: {list(results.keys())}")
|
||||
return results
|
||||
|
||||
|
||||
@task(name="sign-artifacts")
|
||||
def task_sign_artifacts(
|
||||
dist_dir: Path,
|
||||
gpg_passphrase: str,
|
||||
patterns: list[str] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Sign and hash all release artifacts matching the given glob patterns.
|
||||
|
||||
Args:
|
||||
dist_dir: Directory containing artifacts.
|
||||
gpg_passphrase: GPG key passphrase.
|
||||
patterns: Glob patterns to match artifacts. Defaults to common package types.
|
||||
|
||||
Returns:
|
||||
List of generated signature and hash file paths.
|
||||
"""
|
||||
if patterns is None:
|
||||
patterns = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.zst"]
|
||||
|
||||
generated_files: list[Path] = []
|
||||
|
||||
for pattern in patterns:
|
||||
for match in dist_dir.glob(pattern):
|
||||
print(f"Signing: {match.name}")
|
||||
sig_path, hash_path = sign_and_hash(match, gpg_passphrase)
|
||||
generated_files.extend([sig_path, hash_path])
|
||||
|
||||
if not generated_files:
|
||||
print(f"Warning: no artifacts matched patterns {patterns} in {dist_dir}")
|
||||
|
||||
return generated_files
|
||||
|
||||
|
||||
@task(name="get-version")
|
||||
def task_get_version() -> str:
|
||||
"""Read the project version from pyproject.toml via Poetry."""
|
||||
result = subprocess.run(
|
||||
["poetry", "version", "-s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
if not version:
|
||||
raise RuntimeError("Version is empty in pyproject.toml")
|
||||
print(f"Project version: {version}")
|
||||
return version
|
||||
|
||||
|
||||
@task(name="prepare-release-files")
|
||||
def task_prepare_release_files(dist_dir: Path, release_dir: Path) -> list[Path]:
|
||||
"""Collect all release artifacts into a single directory.
|
||||
|
||||
Args:
|
||||
dist_dir: Source directory containing built artifacts.
|
||||
release_dir: Target directory for release files.
|
||||
|
||||
Returns:
|
||||
List of files copied into the release directory.
|
||||
"""
|
||||
release_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
extensions = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.*", "*.asc", "*.sha256"]
|
||||
copied: list[Path] = []
|
||||
|
||||
for ext in extensions:
|
||||
for src in dist_dir.glob(ext):
|
||||
dst = release_dir / src.name
|
||||
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
|
||||
import shutil
|
||||
|
||||
shutil.copy2(src, dst)
|
||||
copied.append(dst)
|
||||
print(f" {src.name}")
|
||||
|
||||
print(f"Prepared {len(copied)} release files in {release_dir}")
|
||||
return copied
|
||||
|
||||
|
||||
@task(name="create-github-release")
|
||||
def task_create_github_release(
|
||||
version: str,
|
||||
release_dir: Path,
|
||||
github_token: str,
|
||||
) -> None:
|
||||
"""Create a GitHub release with artifacts using gh CLI.
|
||||
|
||||
Args:
|
||||
version: Semantic version string (e.g. '0.1.1').
|
||||
release_dir: Directory containing release files.
|
||||
github_token: GitHub token for authentication.
|
||||
"""
|
||||
tag = f"v{version}"
|
||||
files = list(release_dir.iterdir())
|
||||
|
||||
if not files:
|
||||
raise RuntimeError(f"No files found in {release_dir}")
|
||||
|
||||
env = {**os.environ, "GH_TOKEN": github_token}
|
||||
|
||||
cmd = [
|
||||
"gh",
|
||||
"release",
|
||||
"create",
|
||||
tag,
|
||||
"--title",
|
||||
f"Release {tag}",
|
||||
"--latest",
|
||||
] + [str(f) for f in files]
|
||||
|
||||
print(f"Creating GitHub release {tag} with {len(files)} files")
|
||||
subprocess.run(cmd, check=True, env=env)
|
||||
print(f"GitHub release {tag} created successfully")
|
||||
|
||||
|
||||
@task(name="upload-r2")
|
||||
def task_upload_r2(
|
||||
release_dir: Path,
|
||||
gcp_project_id: str,
|
||||
run_id: str,
|
||||
) -> list[str]:
|
||||
"""Upload release artifacts to Cloudflare R2.
|
||||
|
||||
Credentials are fetched from GCP Secrets Manager at runtime.
|
||||
|
||||
Args:
|
||||
release_dir: Directory containing release files.
|
||||
gcp_project_id: GCP project ID for Secrets Manager lookups.
|
||||
run_id: Unique identifier for this build run.
|
||||
|
||||
Returns:
|
||||
List of uploaded R2 object keys.
|
||||
"""
|
||||
if not gcp_project_id:
|
||||
print("GCP_PROJECT_ID not set; skipping R2 upload")
|
||||
return []
|
||||
|
||||
print(f"Uploading to Cloudflare R2 (build {run_id})")
|
||||
return upload_to_r2(release_dir, gcp_project_id, run_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@flow(name="build-linux-flow", log_prints=True)
|
||||
def flow_build_linux() -> dict[str, dict[str, Path]]: # type: ignore[type-arg]
|
||||
"""Build all Linux distribution packages."""
|
||||
config = PipelineConfig()
|
||||
return task_build_linux(config) # type: ignore[return-value]
|
||||
|
||||
|
||||
@flow(name="sign-flow", log_prints=True)
|
||||
def flow_sign(gpg_passphrase: str) -> list[Path]:
|
||||
"""Sign all artifacts in the dist/ directory."""
|
||||
config = PipelineConfig()
|
||||
return task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
|
||||
|
||||
|
||||
@flow(name="release-flow", log_prints=True)
|
||||
def flow_release(
|
||||
github_token: str,
|
||||
gcp_project_id: str = "",
|
||||
run_id: str = "",
|
||||
) -> None:
|
||||
"""Create a GitHub release and optionally upload to Cloudflare R2."""
|
||||
config = PipelineConfig()
|
||||
version = task_get_version()
|
||||
|
||||
release_dir = config.project_root / "release-files"
|
||||
task_prepare_release_files(config.project_root / "dist", release_dir)
|
||||
task_create_github_release(version, release_dir, github_token)
|
||||
|
||||
if gcp_project_id:
|
||||
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
|
||||
|
||||
|
||||
@flow(name="upload-r2-flow", log_prints=True)
|
||||
def flow_upload_r2(
|
||||
gcp_project_id: str,
|
||||
run_id: str = "",
|
||||
release_dir: str = "",
|
||||
) -> list[str]:
|
||||
"""Upload release artifacts to Cloudflare R2 (standalone).
|
||||
|
||||
Args:
|
||||
gcp_project_id: GCP project ID for Secrets Manager lookups.
|
||||
run_id: Build run identifier for R2 path.
|
||||
release_dir: Path to directory containing artifacts. Defaults to
|
||||
<project_root>/release-files.
|
||||
|
||||
Returns:
|
||||
List of uploaded R2 object keys.
|
||||
"""
|
||||
config = PipelineConfig()
|
||||
target_dir = Path(release_dir) if release_dir else config.project_root / "release-files"
|
||||
return task_upload_r2(target_dir, gcp_project_id, run_id or "local") # type: ignore[return-value]
|
||||
|
||||
|
||||
@flow(name="full-pipeline", log_prints=True)
|
||||
def flow_full_pipeline(
|
||||
gpg_passphrase: str = "",
|
||||
github_token: str = "",
|
||||
gcp_project_id: str = "",
|
||||
run_id: str = "",
|
||||
skip_build: bool = False,
|
||||
skip_sign: bool = False,
|
||||
skip_release: bool = False,
|
||||
) -> None:
|
||||
"""Run the complete CI/CD pipeline: build → sign → release → R2.
|
||||
|
||||
Args:
|
||||
gpg_passphrase: GPG key passphrase for signing.
|
||||
github_token: GitHub token for release creation.
|
||||
gcp_project_id: GCP project ID for R2 credential lookup.
|
||||
run_id: Build run identifier for R2 path.
|
||||
skip_build: Skip the Linux build step.
|
||||
skip_sign: Skip the signing step.
|
||||
skip_release: Skip the release + R2 step.
|
||||
"""
|
||||
config = PipelineConfig()
|
||||
|
||||
# Step 1: Build Linux distros
|
||||
if not skip_build:
|
||||
task_build_linux(config)
|
||||
|
||||
# Step 2: Sign artifacts
|
||||
if not skip_sign:
|
||||
if not gpg_passphrase:
|
||||
raise ValueError("--gpg-passphrase is required for signing")
|
||||
task_sign_artifacts(config.project_root / "dist", gpg_passphrase)
|
||||
|
||||
# Step 3: Release
|
||||
if not skip_release:
|
||||
if not github_token:
|
||||
raise ValueError("--github-token is required for release")
|
||||
version = task_get_version()
|
||||
release_dir = config.project_root / "release-files"
|
||||
task_prepare_release_files(config.project_root / "dist", release_dir)
|
||||
task_create_github_release(version, release_dir, github_token)
|
||||
|
||||
if gcp_project_id:
|
||||
task_upload_r2(release_dir, gcp_project_id, run_id or "local")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for running pipeline actions."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CI/CD pipeline orchestration via Prefect + Dagger"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
# build-linux
|
||||
subparsers.add_parser("build-linux", help="Build all Linux distribution packages")
|
||||
|
||||
# sign
|
||||
sign_parser = subparsers.add_parser("sign", help="Sign artifacts in dist/")
|
||||
sign_parser.add_argument("--gpg-passphrase", required=True, help="GPG passphrase")
|
||||
|
||||
# release
|
||||
release_parser = subparsers.add_parser("release", help="Create GitHub release")
|
||||
release_parser.add_argument("--github-token", required=True, help="GitHub token")
|
||||
release_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
|
||||
release_parser.add_argument("--run-id", default="", help="Build run ID")
|
||||
|
||||
# upload-r2
|
||||
r2_parser = subparsers.add_parser("upload-r2", help="Upload artifacts to Cloudflare R2")
|
||||
r2_parser.add_argument("--gcp-project-id", required=True, help="GCP project ID for R2 credentials")
|
||||
r2_parser.add_argument("--run-id", default="", help="Build run ID")
|
||||
r2_parser.add_argument("--release-dir", default="", help="Path to artifact directory")
|
||||
|
||||
# full
|
||||
full_parser = subparsers.add_parser("full", help="Run full pipeline")
|
||||
full_parser.add_argument("--gpg-passphrase", default="", help="GPG passphrase")
|
||||
full_parser.add_argument("--github-token", default="", help="GitHub token")
|
||||
full_parser.add_argument("--gcp-project-id", default="", help="GCP project ID for R2 credentials")
|
||||
full_parser.add_argument("--run-id", default="", help="Build run ID")
|
||||
full_parser.add_argument("--skip-build", action="store_true")
|
||||
full_parser.add_argument("--skip-sign", action="store_true")
|
||||
full_parser.add_argument("--skip-release", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "build-linux":
|
||||
flow_build_linux()
|
||||
elif args.action == "sign":
|
||||
flow_sign(gpg_passphrase=args.gpg_passphrase)
|
||||
elif args.action == "release":
|
||||
flow_release(
|
||||
github_token=args.github_token,
|
||||
gcp_project_id=args.gcp_project_id,
|
||||
run_id=args.run_id,
|
||||
)
|
||||
elif args.action == "upload-r2":
|
||||
flow_upload_r2(
|
||||
gcp_project_id=args.gcp_project_id,
|
||||
run_id=args.run_id,
|
||||
release_dir=args.release_dir,
|
||||
)
|
||||
elif args.action == "full":
|
||||
flow_full_pipeline(
|
||||
gpg_passphrase=args.gpg_passphrase,
|
||||
github_token=args.github_token,
|
||||
gcp_project_id=args.gcp_project_id,
|
||||
run_id=args.run_id,
|
||||
skip_build=args.skip_build,
|
||||
skip_sign=args.skip_sign,
|
||||
skip_release=args.skip_release,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""Cloudflare R2 artifact upload with credentials from GCP Secrets Manager."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
import boto3 # type: ignore[import-untyped]
|
||||
from google.cloud import secretmanager # type: ignore[import-untyped]
|
||||
|
||||
# GCP Secret Manager secret names for R2 credentials
|
||||
_R2_ACCESS_KEY_SECRET = "r2-access-key-id"
|
||||
_R2_SECRET_KEY_SECRET = "r2-secret-access-key"
|
||||
_R2_ENDPOINT_SECRET = "r2-endpoint-url"
|
||||
_R2_BUCKET_SECRET = "r2-bucket-name"
|
||||
|
||||
|
||||
def _fetch_secret(client: Any, project_id: str, secret_id: str) -> str:
|
||||
"""Fetch the latest version of a secret from GCP Secrets Manager.
|
||||
|
||||
Args:
|
||||
client: Secret Manager client.
|
||||
project_id: GCP project ID.
|
||||
secret_id: Name of the secret to retrieve.
|
||||
|
||||
Returns:
|
||||
The secret value as a string.
|
||||
|
||||
Raises:
|
||||
google.api_core.exceptions.NotFound: If the secret does not exist.
|
||||
"""
|
||||
name = f"projects/{project_id}/secrets/{secret_id}/versions/latest"
|
||||
response = client.access_secret_version(request={"name": name})
|
||||
return response.payload.data.decode("utf-8")
|
||||
|
||||
|
||||
def get_r2_credentials(gcp_project_id: str) -> dict[str, str]:
|
||||
"""Retrieve all Cloudflare R2 credentials from GCP Secrets Manager.
|
||||
|
||||
Args:
|
||||
gcp_project_id: GCP project ID containing the secrets.
|
||||
|
||||
Returns:
|
||||
Dictionary with keys: access_key_id, secret_access_key,
|
||||
endpoint_url, bucket_name.
|
||||
"""
|
||||
client: Any = secretmanager.SecretManagerServiceClient() # pyright: ignore
|
||||
|
||||
return {
|
||||
"access_key_id": _fetch_secret(client, gcp_project_id, _R2_ACCESS_KEY_SECRET),
|
||||
"secret_access_key": _fetch_secret(
|
||||
client, gcp_project_id, _R2_SECRET_KEY_SECRET
|
||||
),
|
||||
"endpoint_url": _fetch_secret(client, gcp_project_id, _R2_ENDPOINT_SECRET),
|
||||
"bucket_name": _fetch_secret(client, gcp_project_id, _R2_BUCKET_SECRET),
|
||||
}
|
||||
|
||||
|
||||
def upload_to_r2(
|
||||
release_dir: Path,
|
||||
gcp_project_id: str,
|
||||
run_id: str,
|
||||
) -> list[str]:
|
||||
"""Upload release artifacts to Cloudflare R2.
|
||||
|
||||
Fetches R2 credentials from GCP Secrets Manager, then uploads all
|
||||
files in the release directory to the R2 bucket under a builds/<run_id>/
|
||||
prefix.
|
||||
|
||||
Args:
|
||||
release_dir: Directory containing release files to upload.
|
||||
gcp_project_id: GCP project ID for Secrets Manager lookups.
|
||||
run_id: Unique identifier for this build run.
|
||||
|
||||
Returns:
|
||||
List of uploaded R2 object keys.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If release_dir does not exist.
|
||||
botocore.exceptions.ClientError: If R2 upload fails.
|
||||
"""
|
||||
if not release_dir.is_dir():
|
||||
raise FileNotFoundError(f"Release directory not found: {release_dir}")
|
||||
|
||||
credentials = get_r2_credentials(gcp_project_id)
|
||||
|
||||
s3_client: Any = boto3.client( # pyright: ignore
|
||||
"s3",
|
||||
endpoint_url=credentials["endpoint_url"],
|
||||
aws_access_key_id=credentials["access_key_id"],
|
||||
aws_secret_access_key=credentials["secret_access_key"],
|
||||
)
|
||||
|
||||
bucket = credentials["bucket_name"]
|
||||
prefix = f"builds/{run_id}"
|
||||
uploaded_keys: list[str] = []
|
||||
|
||||
for file_path in sorted(release_dir.iterdir()):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
|
||||
key = f"{prefix}/{file_path.name}"
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
|
||||
extra_args: dict[str, str] = {}
|
||||
if content_type:
|
||||
extra_args["ContentType"] = content_type
|
||||
|
||||
print(f" Uploading {file_path.name} → {key}")
|
||||
s3_client.upload_file( # pyright: ignore[reportUnknownMemberType]
|
||||
str(file_path),
|
||||
bucket,
|
||||
key,
|
||||
ExtraArgs=extra_args,
|
||||
)
|
||||
uploaded_keys.append(key)
|
||||
|
||||
print(
|
||||
f"Uploaded {len(uploaded_keys)} files to R2 bucket '{bucket}' under '{prefix}/'"
|
||||
)
|
||||
return uploaded_keys
|
||||
@@ -0,0 +1,94 @@
|
||||
"""GPG signing and SHA-256 hashing utilities for release artifacts."""
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def gpg_sign_file(file_path: Path, passphrase: str) -> Path:
|
||||
"""Create a detached ASCII-armored GPG signature for a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to sign.
|
||||
passphrase: GPG key passphrase.
|
||||
|
||||
Returns:
|
||||
Path to the generated .asc signature file.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If GPG signing fails.
|
||||
FileNotFoundError: If the input file does not exist.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
sig_path = file_path.with_suffix(file_path.suffix + ".asc")
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".pass", delete=True) as passfile:
|
||||
passfile.write(passphrase)
|
||||
passfile.flush()
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--passphrase-file",
|
||||
passfile.name,
|
||||
"--detach-sign",
|
||||
"--armor",
|
||||
str(file_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
print(f"Signed: {sig_path}")
|
||||
return sig_path
|
||||
|
||||
|
||||
def sha256_hash_file(file_path: Path) -> tuple[str, Path]:
|
||||
"""Compute SHA-256 hash of a file and write a .sha256 checksum file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to hash.
|
||||
|
||||
Returns:
|
||||
Tuple of (hex digest, path to .sha256 file).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the input file does not exist.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
|
||||
digest = sha256.hexdigest()
|
||||
hash_line = f"{digest} {file_path.name}"
|
||||
|
||||
hash_path = file_path.parent / f"{file_path.stem}.sha256"
|
||||
hash_path.write_text(hash_line, encoding="ascii")
|
||||
|
||||
print(f"SHA-256 ({file_path.name}): {digest}")
|
||||
return digest, hash_path
|
||||
|
||||
|
||||
def sign_and_hash(file_path: Path, passphrase: str) -> tuple[Path, Path]:
|
||||
"""Sign a file with GPG and generate its SHA-256 checksum.
|
||||
|
||||
Args:
|
||||
file_path: Path to the artifact to sign and hash.
|
||||
passphrase: GPG key passphrase.
|
||||
|
||||
Returns:
|
||||
Tuple of (signature path, hash file path).
|
||||
"""
|
||||
sig_path = gpg_sign_file(file_path, passphrase)
|
||||
_, hash_path = sha256_hash_file(file_path)
|
||||
return sig_path, hash_path
|
||||
@@ -0,0 +1,81 @@
|
||||
# DEPRECATED: This file is kept for backward compatibility.
|
||||
# Use podman-compose.yml instead:
|
||||
# podman-compose -f podman-compose.yml up --build
|
||||
#
|
||||
# Docker Compose will also read podman-compose.yml if you symlink:
|
||||
# ln -sf podman-compose.yml docker-compose.yml
|
||||
#
|
||||
# --- Original configuration follows (mirrors podman-compose.yml) ---
|
||||
|
||||
# Podman Compose / Docker Compose configuration for local multi-platform builds
|
||||
# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml
|
||||
#
|
||||
# Usage:
|
||||
# Build all distributions: podman-compose up --build
|
||||
# Build specific distro: podman-compose up --build debian
|
||||
#
|
||||
# Each service builds a distribution package and outputs to:
|
||||
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||
# - pkg_dist_{distro}/ - Staging directory for package contents
|
||||
# - dist_{distro}/ - PyInstaller build output
|
||||
|
||||
services:
|
||||
debian:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.debian
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=debian
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
arch:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.arch
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=arch
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
rhel:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.rhel
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=rhel
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
@@ -0,0 +1,72 @@
|
||||
# Podman Compose configuration for local multi-platform builds
|
||||
# Compatible with podman-compose and docker-compose (via podman socket)
|
||||
#
|
||||
# Usage:
|
||||
# Build all distributions: podman-compose up --build
|
||||
# Build specific distro: podman-compose up --build debian
|
||||
#
|
||||
# Each service builds a distribution package and outputs to:
|
||||
# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||
# - pkg_dist_{distro}/ - Staging directory for package contents
|
||||
# - dist_{distro}/ - PyInstaller build output
|
||||
|
||||
services:
|
||||
debian:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.debian
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=debian
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
arch:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.arch
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=arch
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
|
||||
rhel:
|
||||
image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/docker/Dockerfile.rhel
|
||||
args:
|
||||
FPM_VERSION: "1.16.0"
|
||||
volumes:
|
||||
- .:/workspace:Z
|
||||
# Exclude host .venv to prevent conflicts with container Python
|
||||
- /workspace/.venv
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CI_CD=true
|
||||
- DISTRO_TYPE=rhel
|
||||
- FPM_VERSION=1.16.0
|
||||
- POETRY_VIRTUALENVS_IN_PROJECT=false
|
||||
- POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache
|
||||
command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py"
|
||||
Generated
+4591
-405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
+13
-4
@@ -5,7 +5,7 @@ description = "An Android file transfer util for Windows and Linux. MacOS suppor
|
||||
authors = [
|
||||
{ name = "Jason Ross", email = "51939451+JMR-dev@users.noreply.github.com" },
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13, <3.15"
|
||||
dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"]
|
||||
@@ -20,25 +20,34 @@ urllib3 = "^2.0"
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
[project.scripts]
|
||||
# Main application entry point
|
||||
android-file-handler = "src.main:main"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.9.0"
|
||||
black = "^26.3.1"
|
||||
flake8 = "^7.3.0"
|
||||
mypy = "^1.18.2"
|
||||
pre-commit = "^4.3.0"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^8.4.2"
|
||||
pytest = "^9.0.2"
|
||||
pytest-mock = "^3.15.1"
|
||||
pytest-cov = "^7.0.0"
|
||||
|
||||
[tool.poetry.group.build.dependencies]
|
||||
pyinstaller = "^6.1.0"
|
||||
|
||||
[tool.poetry.group.ci]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.ci.dependencies]
|
||||
dagger-io = ">=0.15.0"
|
||||
prefect = ">=3.0.0"
|
||||
boto3 = ">=1.35.0"
|
||||
google-cloud-secret-manager = ">=2.21.0"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py313']
|
||||
|
||||
+135
-18
@@ -4,9 +4,10 @@ import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
from typing import List, TypedDict
|
||||
|
||||
|
||||
class DistroType(Enum):
|
||||
@@ -15,7 +16,19 @@ class DistroType(Enum):
|
||||
RHEL = "rhel"
|
||||
|
||||
|
||||
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess:
|
||||
class DistroConfigDict(TypedDict):
|
||||
"""Type definition for distro configuration."""
|
||||
|
||||
name: str
|
||||
bin_path: str
|
||||
pkg_suffix: str
|
||||
spec_file: str
|
||||
pkg_type: str
|
||||
architecture: str
|
||||
postinstall: str | None
|
||||
|
||||
|
||||
def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess[bytes]:
|
||||
"""Run command and handle errors."""
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
@@ -28,31 +41,138 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_distro_config(distro_type: DistroType) -> dict:
|
||||
def get_distro_config(distro_type: DistroType) -> DistroConfigDict:
|
||||
"""Get configuration for specific distro type."""
|
||||
configs = {
|
||||
configs: dict[DistroType, DistroConfigDict] = {
|
||||
DistroType.DEBIAN: {
|
||||
"name": "Debian",
|
||||
"bin_path": "usr/local/bin",
|
||||
"pkg_suffix": "debian",
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-debian.spec"
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-debian.spec",
|
||||
"pkg_type": "deb",
|
||||
"architecture": "amd64",
|
||||
"postinstall": "scripts/debian_postinst.sh"
|
||||
},
|
||||
DistroType.ARCH: {
|
||||
"name": "Arch",
|
||||
"bin_path": "usr/bin",
|
||||
"pkg_suffix": "arch",
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-arch.spec"
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-arch.spec",
|
||||
"pkg_type": "pacman",
|
||||
"architecture": "x86_64",
|
||||
"postinstall": None
|
||||
},
|
||||
DistroType.RHEL: {
|
||||
"name": "RHEL",
|
||||
"bin_path": "usr/bin",
|
||||
"bin_path": "usr/bin",
|
||||
"pkg_suffix": "rhel",
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-rhel.spec"
|
||||
"spec_file": "scripts/spec_scripts/android-file-handler-rhel.spec",
|
||||
"pkg_type": "rpm",
|
||||
"architecture": "x86_64",
|
||||
"postinstall": "scripts/rhel_postinst.sh"
|
||||
}
|
||||
}
|
||||
return configs[distro_type]
|
||||
|
||||
|
||||
def validate_version(version: str) -> bool:
|
||||
"""Validate semantic version format."""
|
||||
semver_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$'
|
||||
return bool(re.match(semver_pattern, version))
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get version from Poetry and validate it."""
|
||||
result = subprocess.run(
|
||||
["poetry", "version", "-s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
|
||||
if not version:
|
||||
print("ERROR: Version is empty in pyproject.toml")
|
||||
sys.exit(1)
|
||||
|
||||
if not validate_version(version):
|
||||
print(f"ERROR: Invalid version format in pyproject.toml: {version}")
|
||||
print("Expected semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.123)")
|
||||
sys.exit(1)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def package_with_fpm(distro_type: DistroType, version: str, project_root: Path) -> None:
|
||||
"""Package the built application using fpm."""
|
||||
config = get_distro_config(distro_type)
|
||||
pkg_dir = project_root / f"pkg_dist_{config['pkg_suffix']}"
|
||||
dist_dir = project_root / "dist"
|
||||
|
||||
print(f"\n=== Packaging {config['name']} with fpm ===")
|
||||
|
||||
# Ensure dist directory exists
|
||||
dist_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Check if icon exists
|
||||
icon_path = pkg_dir / "usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
|
||||
icon_included = icon_path.exists()
|
||||
|
||||
# Build package items list
|
||||
pkg_items = [
|
||||
f"{config['bin_path']}/android-file-handler",
|
||||
"usr/share/applications/android-file-handler.desktop"
|
||||
]
|
||||
|
||||
if icon_included:
|
||||
pkg_items.append("usr/share/icons/hicolor/256x256/apps/android-file-handler.png")
|
||||
else:
|
||||
print("Note: icon not present, packaging without icon")
|
||||
|
||||
# Build fpm command based on distro type
|
||||
fpm_cmd = [
|
||||
"fpm",
|
||||
"-s", "dir",
|
||||
"-t", config["pkg_type"],
|
||||
"-n", "android-file-handler",
|
||||
"-v", version,
|
||||
"--architecture", config["architecture"],
|
||||
"-C", str(pkg_dir)
|
||||
]
|
||||
|
||||
# Add distro-specific options
|
||||
if distro_type == DistroType.DEBIAN:
|
||||
output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb"
|
||||
postinstall = config["postinstall"]
|
||||
fpm_cmd.extend([
|
||||
"--deb-user", "root",
|
||||
"--deb-group", "root",
|
||||
*(["--after-install", postinstall] if postinstall else []),
|
||||
"-p", str(output_file)
|
||||
])
|
||||
elif distro_type == DistroType.ARCH:
|
||||
output_file = dist_dir / f"android-file-handler-{version}-1-{config['architecture']}.pkg.tar.zst"
|
||||
fpm_cmd.extend([
|
||||
"-p", str(output_file)
|
||||
])
|
||||
elif distro_type == DistroType.RHEL:
|
||||
output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm"
|
||||
postinstall = config["postinstall"]
|
||||
fpm_cmd.extend([
|
||||
*(["--after-install", postinstall] if postinstall else []),
|
||||
"-p", str(output_file)
|
||||
])
|
||||
|
||||
# Add package items
|
||||
fpm_cmd.extend(pkg_items)
|
||||
|
||||
# Run fpm
|
||||
print(f"Creating package: {output_file}")
|
||||
run_command(fpm_cmd, working_dir=str(project_root))
|
||||
|
||||
print(f"Package created successfully: {output_file}")
|
||||
|
||||
|
||||
def prompt_distro_selection() -> List[DistroType]:
|
||||
"""Prompt user for distro selection."""
|
||||
print("Select distribution(s) to build for:")
|
||||
@@ -60,7 +180,7 @@ def prompt_distro_selection() -> List[DistroType]:
|
||||
print("2. Arch")
|
||||
print("3. RHEL")
|
||||
print("4. All distributions")
|
||||
|
||||
|
||||
while True:
|
||||
choice = input("Enter choice (1-4): ").strip()
|
||||
if choice == "1":
|
||||
@@ -161,7 +281,7 @@ StartupNotify=true
|
||||
pkg_items.append("usr/share/icons/hicolor/256x256/apps/android-file-handler.png")
|
||||
|
||||
# Debug listing
|
||||
print(f"Packaging the following items for {config['name']} (relative to {pkg_dir}):")
|
||||
print(f"Prepared items for {config['name']} (relative to {pkg_dir}):")
|
||||
for item in pkg_items:
|
||||
print(f" - {item}")
|
||||
item_path = pkg_dir / item
|
||||
@@ -171,6 +291,9 @@ StartupNotify=true
|
||||
else:
|
||||
print(f" (missing) {item_path}")
|
||||
|
||||
# Package with fpm
|
||||
package_with_fpm(distro_type, version, project_root)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Get project root (parent of scripts directory)
|
||||
@@ -211,14 +334,8 @@ def main() -> None:
|
||||
run_command(["poetry", "lock"])
|
||||
run_command(["poetry", "install"])
|
||||
|
||||
# Get version from Poetry
|
||||
result = subprocess.run(
|
||||
["poetry", "version", "-s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
# Get and validate version from Poetry
|
||||
version = get_version()
|
||||
print(f"Version: {version}")
|
||||
|
||||
# Build for selected distributions
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# bootstrap.ps1 — Windows developer environment bootstrapper for Android File Handler
|
||||
# - Installs Chocolatey (if missing)
|
||||
# - Installs Vagrant (Chocolatey or direct from HashiCorp)
|
||||
# - Installs pyenv-win (if missing)
|
||||
# - Installs Python build dependencies (Chocolatey)
|
||||
# - Installs zlib from source (https://zlib.net/current/zlib.tar.gz)
|
||||
# - Installs libffi from GitHub releases (https://github.com/libffi/libffi)
|
||||
# - Installs latest Python (from pyproject.toml)
|
||||
# - Sets that Python as global default
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== Android File Handler Windows Dev Bootstrap ===" -ForegroundColor Cyan
|
||||
|
||||
# --- Install Chocolatey ---
|
||||
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
}
|
||||
|
||||
# --- Install Vagrant ---
|
||||
$vagrantInstalled = Get-Command vagrant -ErrorAction SilentlyContinue
|
||||
if (-not $vagrantInstalled) {
|
||||
Write-Host "Installing Vagrant..." -ForegroundColor Yellow
|
||||
$chocoAvailable = Get-Command choco -ErrorAction SilentlyContinue
|
||||
if ($chocoAvailable) {
|
||||
Write-Host " Installing via Chocolatey..." -ForegroundColor Yellow
|
||||
choco install vagrant -y --no-progress
|
||||
}
|
||||
else {
|
||||
Write-Host " Chocolatey not available — downloading from HashiCorp..." -ForegroundColor Yellow
|
||||
$vagrantVersion = "2.4.9"
|
||||
$msiUrl = "https://releases.hashicorp.com/vagrant/$vagrantVersion/vagrant_${vagrantVersion}_windows_amd64.msi"
|
||||
$msiPath = "$env:TEMP\vagrant_${vagrantVersion}_windows_amd64.msi"
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Write-Host " Downloading $msiUrl ..." -ForegroundColor Yellow
|
||||
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $msiPath)
|
||||
Write-Host " Installing MSI..." -ForegroundColor Yellow
|
||||
$msiProc = Start-Process -FilePath "msiexec.exe" `
|
||||
-ArgumentList "/i", $msiPath, "/qn", "/norestart" `
|
||||
-Wait -PassThru
|
||||
if ($msiProc.ExitCode -ne 0) {
|
||||
Write-Host "ERROR: Vagrant MSI install failed with exit code $($msiProc.ExitCode)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Remove-Item $msiPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
Write-Host " Vagrant installed: $(vagrant --version)" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Vagrant already installed: $(vagrant --version)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- Install pyenv-win ---
|
||||
$pyenvRoot = "$env:USERPROFILE\.pyenv"
|
||||
$pyenvBin = "$pyenvRoot\pyenv-win\bin\pyenv.bat"
|
||||
if (-not (Test-Path $pyenvBin)) {
|
||||
Write-Host "Installing pyenv-win..." -ForegroundColor Yellow
|
||||
choco install pyenv-win -y --no-progress
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
}
|
||||
else {
|
||||
Write-Host "pyenv-win already installed." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- Install Python build dependencies ---
|
||||
# Package IDs verified against https://community.chocolatey.org/packages
|
||||
# zlib has no Chocolatey package; libffi has no Chocolatey package — both omitted
|
||||
$buildDeps = @('visualcpp-build-tools', 'make', 'openssl.light', 'bzip2', 'SQLite')
|
||||
$installedPkgs = choco list --no-progress --limit-output 2>&1 |
|
||||
Where-Object { $_ -match '\|' } |
|
||||
ForEach-Object { ($_ -split '\|')[0].ToLower() }
|
||||
$toInstall = @($buildDeps | Where-Object { $_.ToLower() -notin $installedPkgs })
|
||||
if ($toInstall.Count -gt 0) {
|
||||
Write-Host "Installing Python build dependencies: $($toInstall -join ', ')..." -ForegroundColor Yellow
|
||||
choco install -y --no-progress @toInstall
|
||||
} else {
|
||||
Write-Host "Python build dependencies already installed." -ForegroundColor Green
|
||||
}
|
||||
|
||||
|
||||
# Shared install root for libraries not on Chocolatey
|
||||
$devLibsRoot = "$env:USERPROFILE\.devlibs"
|
||||
New-Item -ItemType Directory -Path $devLibsRoot -Force | Out-Null
|
||||
|
||||
# --- Install zlib from source (https://zlib.net/current/zlib.tar.gz) ---
|
||||
$zlibInstallDir = "$devLibsRoot\zlib"
|
||||
if (-not (Test-Path "$zlibInstallDir\include\zlib.h")) {
|
||||
Write-Host "Installing zlib from source..." -ForegroundColor Yellow
|
||||
|
||||
# Locate MSVC build environment via vswhere
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (-not (Test-Path $vswhere)) {
|
||||
Write-Host "ERROR: vswhere.exe not found — Visual Studio Build Tools required to build zlib." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$vsPath = (& $vswhere -latest -products * -requires Microsoft.VisualCpp.Tools.HostX86.TargetX64 -property installationPath 2>&1).Trim()
|
||||
$vcvarsall = "$vsPath\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
if (-not (Test-Path $vcvarsall)) {
|
||||
Write-Host "ERROR: vcvarsall.bat not found at $vcvarsall" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Download source tarball
|
||||
$zlibTar = "$env:TEMP\zlib.tar.gz"
|
||||
Write-Host " Downloading zlib source..." -ForegroundColor Yellow
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
(New-Object System.Net.WebClient).DownloadFile('https://zlib.net/current/zlib.tar.gz', $zlibTar)
|
||||
|
||||
# Extract (tar.exe ships with Windows 10 1803+)
|
||||
$zlibBuildDir = "$env:TEMP\zlib-build"
|
||||
if (Test-Path $zlibBuildDir) { Remove-Item $zlibBuildDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $zlibBuildDir | Out-Null
|
||||
tar -xzf $zlibTar -C $zlibBuildDir --strip-components=1
|
||||
|
||||
# Build with MSVC nmake via a wrapper cmd script
|
||||
Write-Host " Building zlib with MSVC nmake..." -ForegroundColor Yellow
|
||||
$buildCmd = "$env:TEMP\zlib-build.cmd"
|
||||
"@echo off`r`ncall `"$vcvarsall`" x64`r`ncd /d `"$zlibBuildDir`"`r`nnmake -f win32\Makefile.msc`r`n" |
|
||||
Set-Content $buildCmd -Encoding ASCII
|
||||
$proc = Start-Process cmd.exe -ArgumentList "/c `"$buildCmd`"" -Wait -PassThru -NoNewWindow
|
||||
Remove-Item $buildCmd -Force -ErrorAction SilentlyContinue
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-Host "ERROR: zlib build failed (exit $($proc.ExitCode))" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Install headers and libs
|
||||
New-Item -ItemType Directory -Path "$zlibInstallDir\include" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$zlibInstallDir\lib" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$zlibInstallDir\bin" -Force | Out-Null
|
||||
Copy-Item "$zlibBuildDir\zlib.h" "$zlibInstallDir\include\"
|
||||
Copy-Item "$zlibBuildDir\zconf.h" "$zlibInstallDir\include\"
|
||||
Copy-Item "$zlibBuildDir\zlib.lib" "$zlibInstallDir\lib\"
|
||||
Copy-Item "$zlibBuildDir\zdll.lib" "$zlibInstallDir\lib\" -ErrorAction SilentlyContinue
|
||||
Copy-Item "$zlibBuildDir\zlib1.dll" "$zlibInstallDir\bin\" -ErrorAction SilentlyContinue
|
||||
|
||||
# Cleanup
|
||||
Remove-Item $zlibTar -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $zlibBuildDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "zlib installed to $zlibInstallDir" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "zlib already installed at $zlibInstallDir" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Add zlib bin dir to user PATH (persists across sessions; also updates current session)
|
||||
$zlibBinDir = "$zlibInstallDir\bin"
|
||||
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if ($userPath -notmatch [regex]::Escape($zlibBinDir)) {
|
||||
[System.Environment]::SetEnvironmentVariable('Path', "$userPath;$zlibBinDir", 'User')
|
||||
Write-Host " Added $zlibBinDir to user PATH" -ForegroundColor Green
|
||||
}
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' +
|
||||
[System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
|
||||
# --- Install libffi from GitHub releases (https://github.com/libffi/libffi) ---
|
||||
$libffiInstallDir = "$devLibsRoot\libffi"
|
||||
if (-not (Test-Path "$libffiInstallDir\include\ffi.h")) {
|
||||
Write-Host "Fetching latest libffi release from GitHub..." -ForegroundColor Yellow
|
||||
|
||||
$apiResponse = Invoke-RestMethod `
|
||||
-Uri 'https://api.github.com/repos/libffi/libffi/releases/latest' `
|
||||
-Headers @{ 'User-Agent' = 'android-file-handler-bootstrap' }
|
||||
$libffiTag = $apiResponse.tag_name # e.g. "v3.5.2"
|
||||
$libffiVersion = $libffiTag -replace '^v', '' # e.g. "3.5.2"
|
||||
Write-Host " Latest libffi: $libffiTag" -ForegroundColor Yellow
|
||||
|
||||
$zipName = "libffi-$libffiVersion-x86-64bit-msvc-binaries.zip"
|
||||
$downloadUrl = "https://github.com/libffi/libffi/releases/download/$libffiTag/$zipName"
|
||||
$zipPath = "$env:TEMP\$zipName"
|
||||
|
||||
Write-Host " Downloading $zipName..." -ForegroundColor Yellow
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
(New-Object System.Net.WebClient).DownloadFile($downloadUrl, $zipPath)
|
||||
|
||||
# Extract — zip contains a single top-level subdirectory
|
||||
$extractTemp = "$env:TEMP\libffi-extract"
|
||||
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force }
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractTemp -Force
|
||||
|
||||
if (Test-Path $libffiInstallDir) { Remove-Item $libffiInstallDir -Recurse -Force }
|
||||
$innerDir = Get-ChildItem $extractTemp -Directory | Select-Object -First 1
|
||||
if ($innerDir) {
|
||||
Move-Item $innerDir.FullName $libffiInstallDir
|
||||
} else {
|
||||
# Flat zip — use the extract root directly
|
||||
Move-Item $extractTemp $libffiInstallDir
|
||||
}
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
Write-Host "libffi $libffiVersion installed to $libffiInstallDir" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "libffi already installed at $libffiInstallDir" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- Parse Python version from pyproject.toml ---
|
||||
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..")
|
||||
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
|
||||
if (-not (Test-Path $pyprojectPath)) {
|
||||
Write-Host "ERROR: pyproject.toml not found at $pyprojectPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$pyproject = Get-Content $pyprojectPath -Raw
|
||||
$pyverMatch = $pyproject | Select-String -Pattern 'requires-python\s*=\s*"([^"]+)"' | ForEach-Object { $_.Matches[0].Groups[1].Value }
|
||||
if (-not $pyverMatch) {
|
||||
Write-Host "ERROR: Could not find requires-python in pyproject.toml" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
# Use the highest version in the range (e.g. ">=3.13, <3.15" → 3.14.x)
|
||||
$pyver = $pyverMatch -replace '[^0-9.,<>= ]', ''
|
||||
$maxVer = ($pyver -split ',') | Where-Object { $_ -match '<' } | ForEach-Object { $_ -replace '[^0-9.]', '' } | Sort-Object -Descending | Select-Object -First 1
|
||||
if ($maxVer) {
|
||||
$major, $minor = $maxVer -split '\.'
|
||||
$targetMinor = [int]$minor - 1
|
||||
$targetVer = "$major.$targetMinor.x"
|
||||
} else {
|
||||
$targetVer = $pyver -replace '[^0-9.]', ''
|
||||
}
|
||||
Write-Host "Target Python version: $targetVer" -ForegroundColor Cyan
|
||||
|
||||
# --- Install and set global Python version ---
|
||||
$pyenv = "pyenv"
|
||||
if (-not (Get-Command $pyenv -ErrorAction SilentlyContinue)) {
|
||||
$pyenv = "$pyenvBin"
|
||||
}
|
||||
|
||||
$versionPrefix = $targetVer -replace '\.x$', ''
|
||||
$installedVersions = (& $pyenv versions 2>&1) | Out-String
|
||||
if ($installedVersions -notmatch [regex]::Escape($versionPrefix)) {
|
||||
Write-Host "Installing Python $targetVer via pyenv-win..." -ForegroundColor Yellow
|
||||
& $pyenv install $targetVer -q
|
||||
} else {
|
||||
Write-Host "Python $targetVer already installed via pyenv." -ForegroundColor Green
|
||||
}
|
||||
|
||||
$currentGlobal = ((& $pyenv global 2>&1) | Out-String).Trim()
|
||||
if ($currentGlobal -notmatch [regex]::Escape($versionPrefix)) {
|
||||
& $pyenv global $targetVer
|
||||
Write-Host "pyenv-win global version set to $targetVer" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "pyenv-win global already set to $currentGlobal" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "=== Bootstrap complete ===" -ForegroundColor Cyan
|
||||
@@ -9,8 +9,10 @@
|
||||
# For reproducibility, pin to a specific date tag like: archlinux:base-20251016
|
||||
FROM archlinux:latest
|
||||
|
||||
# Set build argument for fpm version (can be overridden at build time)
|
||||
# Set build arguments
|
||||
ARG FPM_VERSION=1.16.0
|
||||
ARG PYTHON_VERSION=3.14.3
|
||||
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
|
||||
|
||||
# Install system dependencies (Arch) including Python build dependencies
|
||||
RUN pacman -Syu --noconfirm \
|
||||
@@ -56,24 +58,26 @@ RUN gem install --no-document -v "${FPM_VERSION}" fpm && \
|
||||
|
||||
|
||||
|
||||
# Install pyenv
|
||||
ENV PYENV_ROOT="/root/.pyenv"
|
||||
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||
# Download Python source and verify SHA256 checksum against python.org
|
||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
||||
|
||||
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||
|
||||
# Install Python 3.12 via pyenv with tkinter support
|
||||
# The tk and tcl packages must be installed before this step for _tkinter to be compiled
|
||||
RUN eval "$(pyenv init -)" && \
|
||||
# Build and install Python from source
|
||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
||||
cd Python-${PYTHON_VERSION} && \
|
||||
LDFLAGS="-L/usr/lib" \
|
||||
CPPFLAGS="-I/usr/include" \
|
||||
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||
pyenv install 3.13 && \
|
||||
pyenv global 3.13 && \
|
||||
pyenv rehash
|
||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
||||
ldconfig && \
|
||||
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
|
||||
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
|
||||
|
||||
# Update PATH to include pyenv shims
|
||||
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||
# Update PATH to include Python installation
|
||||
ENV PATH="/usr/local/bin:$PATH"
|
||||
|
||||
# Verify Python has tkinter support
|
||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
# Use Debian 13 "Trixie" (latest stable release)
|
||||
FROM debian:13
|
||||
|
||||
# Set build argument for fpm version (can be overridden at build time)
|
||||
# Set build arguments
|
||||
ARG FPM_VERSION=1.16.0
|
||||
ARG PYTHON_VERSION=3.14.3
|
||||
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
|
||||
|
||||
# Install system dependencies including Python build dependencies
|
||||
RUN apt-get update && \
|
||||
@@ -41,24 +43,26 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install pyenv
|
||||
ENV PYENV_ROOT="/root/.pyenv"
|
||||
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||
# Download Python source and verify SHA256 checksum against python.org
|
||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
||||
|
||||
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||
|
||||
# Install Python 3.12 via pyenv with tkinter support
|
||||
# The tk8.6-dev package must be installed before this step for _tkinter to be compiled
|
||||
RUN eval "$(pyenv init -)" && \
|
||||
# Build and install Python from source
|
||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
||||
cd Python-${PYTHON_VERSION} && \
|
||||
LDFLAGS="-L/usr/lib/x86_64-linux-gnu" \
|
||||
CPPFLAGS="-I/usr/include/tcl8.6" \
|
||||
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||
pyenv install 3.13 && \
|
||||
pyenv global 3.13 && \
|
||||
pyenv rehash
|
||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
||||
ldconfig && \
|
||||
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
|
||||
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
|
||||
|
||||
# Update PATH to include pyenv shims
|
||||
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||
# Update PATH to include Python installation
|
||||
ENV PATH="/usr/local/bin:$PATH"
|
||||
|
||||
# Verify Python has tkinter support
|
||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
FROM fedora:42
|
||||
|
||||
# Set build argument for fpm version (can be overridden at build time)
|
||||
# Set build arguments
|
||||
ARG FPM_VERSION=1.16.0
|
||||
ARG PYTHON_VERSION=3.14.3
|
||||
ARG PYTHON_SHA256=d7fe130d0501ae047ca318fa92aa642603ab6f217901015a1df6ce650d5470cd
|
||||
|
||||
# Install system dependencies including tk8-devel for Python tkinter support
|
||||
# Using tk8 (version 8.6) instead of tk (version 9.0) for Python 3.12 compatibility
|
||||
@@ -42,24 +44,26 @@ RUN dnf -y update && \
|
||||
libXrender-devel && \
|
||||
dnf clean all
|
||||
|
||||
# Install pyenv
|
||||
ENV PYENV_ROOT="/root/.pyenv"
|
||||
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||
# Download Python source and verify SHA256 checksum against python.org
|
||||
RUN wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
|
||||
-O /tmp/Python-${PYTHON_VERSION}.tgz && \
|
||||
echo "${PYTHON_SHA256} /tmp/Python-${PYTHON_VERSION}.tgz" | sha256sum -c -
|
||||
|
||||
RUN git clone https://github.com/pyenv/pyenv.git /root/.pyenv
|
||||
|
||||
# Install Python 3.12 via pyenv with tkinter support
|
||||
# The tk8-devel package must be installed before this step for _tkinter to be compiled
|
||||
RUN eval "$(pyenv init -)" && \
|
||||
# Build and install Python from source
|
||||
RUN cd /tmp && tar xzf Python-${PYTHON_VERSION}.tgz && \
|
||||
cd Python-${PYTHON_VERSION} && \
|
||||
LDFLAGS="-L/usr/lib64" \
|
||||
CPPFLAGS="-I/usr/include" \
|
||||
PYTHON_CONFIGURE_OPTS="--enable-shared" \
|
||||
pyenv install 3.13 && \
|
||||
pyenv global 3.13 && \
|
||||
pyenv rehash
|
||||
./configure --enable-shared --with-ensurepip=install --prefix=/usr/local && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
echo "/usr/local/lib" > /etc/ld.so.conf.d/python.conf && \
|
||||
ldconfig && \
|
||||
ln -sf /usr/local/bin/python3 /usr/local/bin/python && \
|
||||
rm -rf /tmp/Python-${PYTHON_VERSION} /tmp/Python-${PYTHON_VERSION}.tgz
|
||||
|
||||
# Update PATH to include pyenv shims
|
||||
ENV PATH="/root/.pyenv/shims:$PATH"
|
||||
# Update PATH to include Python installation
|
||||
ENV PATH="/usr/local/bin:$PATH"
|
||||
|
||||
# Verify Python has tkinter support
|
||||
RUN python3 -c "import tkinter; import _tkinter; print('tkinter support verified')" || \
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Container Build Environment
|
||||
|
||||
This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline.
|
||||
|
||||
The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Podman Compose (Recommended)
|
||||
|
||||
Build for all distributions:
|
||||
```bash
|
||||
podman-compose up --build
|
||||
```
|
||||
|
||||
Build for a specific distribution:
|
||||
```bash
|
||||
podman-compose up --build debian
|
||||
podman-compose up --build arch
|
||||
podman-compose up --build rhel
|
||||
```
|
||||
|
||||
### Using Prefect + Dagger (CI Pipeline Locally)
|
||||
|
||||
The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally:
|
||||
```bash
|
||||
poetry install --with ci
|
||||
poetry run python -m ci.prefect_flow build-linux
|
||||
```
|
||||
|
||||
### Manual Podman Build
|
||||
|
||||
Build the image:
|
||||
```bash
|
||||
# Debian
|
||||
podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder .
|
||||
|
||||
# Arch
|
||||
podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder .
|
||||
|
||||
# RHEL/Fedora
|
||||
podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder .
|
||||
```
|
||||
|
||||
Run the build:
|
||||
```bash
|
||||
# Debian
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder
|
||||
|
||||
# Arch
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder
|
||||
|
||||
# RHEL/Fedora
|
||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
After building, you'll find:
|
||||
|
||||
- `dist/` - Final packaged files (.deb, .rpm, .pkg.tar.zst)
|
||||
- `pkg_dist_{distro}/` - Staging directories for package contents
|
||||
- `dist_{distro}/` - PyInstaller build outputs
|
||||
|
||||
## Images
|
||||
|
||||
### Debian Builder
|
||||
- **Image**: `ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie`
|
||||
- **Base**: `debian:13`
|
||||
- **Python**: 3.13 (via pyenv)
|
||||
- **Tools**: Poetry, fpm, PyInstaller
|
||||
|
||||
### Arch Builder
|
||||
- **Image**: `ghcr.io/jmr-dev/android-file-handler-arch-builder:latest`
|
||||
- **Base**: `archlinux:latest`
|
||||
- **Python**: 3.13 (via pyenv)
|
||||
- **Tools**: Poetry, fpm, PyInstaller
|
||||
|
||||
### RHEL Builder
|
||||
- **Image**: `ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42`
|
||||
- **Base**: `fedora:42`
|
||||
- **Python**: 3.13 (via pyenv)
|
||||
- **Tools**: Poetry, fpm, PyInstaller
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Virtualenv Conflicts
|
||||
|
||||
The Podman Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`.
|
||||
|
||||
If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration.
|
||||
|
||||
### SELinux (Fedora/RHEL hosts)
|
||||
|
||||
Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts.
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
Remove build artifacts:
|
||||
```bash
|
||||
rm -rf dist pkg_dist_* dist_*
|
||||
```
|
||||
|
||||
Remove Podman containers and volumes:
|
||||
```bash
|
||||
podman-compose down -v
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Override FPM Version
|
||||
|
||||
Build with a specific fpm version:
|
||||
```bash
|
||||
docker-compose build --build-arg FPM_VERSION=1.15.0 debian
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All builds use these environment variables:
|
||||
- `CI_CD=true` - Runs in CI/CD mode
|
||||
- `DISTRO_TYPE` - Set to `debian`, `arch`, or `rhel`
|
||||
- `FPM_VERSION` - Version of fpm to use (default: 1.16.0)
|
||||
|
||||
## Notes
|
||||
|
||||
- All images use Python 3.13 built from source with tkinter support
|
||||
- The builds are identical to what runs in GitHub Actions
|
||||
- Poetry and fpm are pre-installed in all images
|
||||
- The Python build script handles both PyInstaller and fpm packaging
|
||||
@@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# Ensure installed binary is executable
|
||||
if [ -f /usr/local/bin/android-file-handler ]; then
|
||||
if [ -f /usr/bin/android-file-handler ]; then
|
||||
chmod 0755 /usr/bin/android-file-handler || true
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
block_cipher = None
|
||||
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
|
||||
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
|
||||
a = Analysis(
|
||||
['../../src/main.py'],
|
||||
pathex=['src'],
|
||||
[os.path.join(SRC_DIR, 'main.py')],
|
||||
pathex=[SRC_DIR],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('../../src/gui', 'gui'),
|
||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# GUI modules
|
||||
@@ -33,7 +36,8 @@ a = Analysis(
|
||||
'managers.transfer_manager',
|
||||
# Utility modules
|
||||
'utils',
|
||||
'utils.file_deduplication'
|
||||
'utils.file_deduplication',
|
||||
'utils.security_utils'
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import os
|
||||
block_cipher = None
|
||||
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
|
||||
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
|
||||
a = Analysis(
|
||||
['../../src/main.py'],
|
||||
pathex=['src'],
|
||||
[os.path.join(SRC_DIR, 'main.py')],
|
||||
pathex=[SRC_DIR],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('../../src/gui', 'gui'),
|
||||
('../../scripts/debian_postinst.sh', 'scripts'),
|
||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
||||
(os.path.join(SPEC_DIR, '..', 'debian_postinst.sh'), 'scripts'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# GUI modules
|
||||
@@ -35,7 +38,8 @@ a = Analysis(
|
||||
'managers.transfer_manager',
|
||||
# Utility modules
|
||||
'utils',
|
||||
'utils.file_deduplication'
|
||||
'utils.file_deduplication',
|
||||
'utils.security_utils'
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
block_cipher = None
|
||||
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
|
||||
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
|
||||
a = Analysis(
|
||||
['../../src/main.py'],
|
||||
pathex=['src'],
|
||||
[os.path.join(SRC_DIR, 'main.py')],
|
||||
pathex=[SRC_DIR],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('../../src/gui', 'gui'),
|
||||
('../../scripts/rhel_postinst.sh', 'scripts'),
|
||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
||||
(os.path.join(SPEC_DIR, '..', 'rhel_postinst.sh'), 'scripts'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# GUI modules
|
||||
@@ -34,7 +37,8 @@ a = Analysis(
|
||||
'managers.transfer_manager',
|
||||
# Utility modules
|
||||
'utils',
|
||||
'utils.file_deduplication'
|
||||
'utils.file_deduplication',
|
||||
'utils.security_utils'
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import os
|
||||
block_cipher = None
|
||||
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC))
|
||||
SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src'))
|
||||
a = Analysis(
|
||||
['../../src/main.py'],
|
||||
pathex=['../..'],
|
||||
[os.path.join(SRC_DIR, 'main.py')],
|
||||
pathex=[SRC_DIR],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('../../src/gui', 'gui'),
|
||||
('../windows/first_run_install.ps1', 'scripts/windows')
|
||||
(os.path.join(SRC_DIR, 'gui'), 'gui'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# GUI modules
|
||||
@@ -35,7 +37,8 @@ a = Analysis(
|
||||
'managers.transfer_manager',
|
||||
# Utility modules
|
||||
'utils',
|
||||
'utils.file_deduplication'
|
||||
'utils.file_deduplication',
|
||||
'utils.security_utils'
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
@@ -67,5 +70,5 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon='../../icon_media/robot_files_256.ico',
|
||||
icon=os.path.join(SPEC_DIR, '..', '..', 'icon_media', 'robot_files_256.ico'),
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
; Inno Setup script for Android File Handler
|
||||
; Compiles a Windows installer from the PyInstaller one-file executable.
|
||||
;
|
||||
; Usage (CI):
|
||||
; iscc scripts\windows\android-file-handler-setup.iss /DMyAppVersion=1.2.3
|
||||
;
|
||||
; Usage (local, from repo root):
|
||||
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.1.1"
|
||||
#endif
|
||||
|
||||
#define MyAppName "Android File Handler"
|
||||
#define MyAppPublisher "Jason Ross"
|
||||
#define MyAppURL "https://github.com/JMR-dev/android-file-handler"
|
||||
#define MyAppExeName "android-file-handler-windows.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{8F2B3A7E-4D1C-4E8F-9A2B-6C7D8E9F0A1B}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
; AppVerName controls the registry DisplayName value. Without it Inno Setup
|
||||
; defaults to "AppName version AppVersion" which breaks exact-name lookups.
|
||||
; Windows convention: DisplayName = product name, DisplayVersion = version.
|
||||
AppVerName={#MyAppName}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
LicenseFile=..\..\LICENSE.txt
|
||||
OutputDir=..\..\dist
|
||||
OutputBaseFilename=android-file-handler-setup
|
||||
SetupIconFile=..\..\icon_media\robot_files_256.ico
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma2/normal
|
||||
LZMADictionarySize=16384
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequired=admin
|
||||
MinVersion=10.0
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "..\..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -1,51 +0,0 @@
|
||||
param(
|
||||
[string]$ExePath = "$PSScriptRoot\..\..\dist\android-file-handler.exe",
|
||||
[string]$IconPath = "$PSScriptRoot\..\..\assets\icons\android-file-handler.ico",
|
||||
[string]$AppName = "Android File Handler"
|
||||
)
|
||||
|
||||
function Ensure-Elevated {
|
||||
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||
# Relaunch the script with elevation
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "powershell.exe"
|
||||
$psi.Arguments = "-ExecutionPolicy Bypass -File `"$PSCommandPath`""
|
||||
$psi.Verb = "runas"
|
||||
try {
|
||||
[System.Diagnostics.Process]::Start($psi) | Out-Null
|
||||
Exit 0
|
||||
} catch {
|
||||
Write-Error "Elevation required to install to Program Files."
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ensure-Elevated
|
||||
|
||||
$destDir = Join-Path ${env:ProgramFiles} $AppName
|
||||
if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir | Out-Null }
|
||||
|
||||
$resolvedExe = Resolve-Path -Path $ExePath -ErrorAction SilentlyContinue
|
||||
if (-not $resolvedExe) {
|
||||
Write-Error "Application executable not found at $ExePath"
|
||||
Exit 1
|
||||
}
|
||||
|
||||
Copy-Item -Path $resolvedExe -Destination (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -Force
|
||||
|
||||
# Create Start Menu shortcut
|
||||
$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
||||
$appFolder = Join-Path $programs $AppName
|
||||
if (-not (Test-Path $appFolder)) { New-Item -ItemType Directory -Path $appFolder | Out-Null }
|
||||
|
||||
$shortcutPath = Join-Path $appFolder "$AppName.lnk"
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$sc = $wsh.CreateShortcut($shortcutPath)
|
||||
$sc.TargetPath = (Join-Path $destDir (Split-Path $resolvedExe -Leaf))
|
||||
$sc.WorkingDirectory = $destDir
|
||||
if (Test-Path $IconPath) { $sc.IconLocation = Resolve-Path $IconPath }
|
||||
$sc.Save()
|
||||
|
||||
Write-Output "Installed $AppName to $destDir and created Start Menu shortcut."
|
||||
Exit 0
|
||||
@@ -0,0 +1,334 @@
|
||||
# run_vagrant_tests.ps1 — Host-side orchestrator for Vagrant Windows 11 test cycle.
|
||||
#
|
||||
# Workflow:
|
||||
# 1. Build the installer locally (PyInstaller + Inno Setup)
|
||||
# 2. Ensure the Vagrant VM is running and has a "clean" snapshot
|
||||
# 3. Restore to the clean snapshot
|
||||
# 4. Transfer build assets to the VM and compile the installer (Inno Setup)
|
||||
# 5. Run the test script inside the VM via PSSession
|
||||
# 6. Retrieve screenshots and results to the host
|
||||
# 7. Print results summary to CLI
|
||||
# 8. Revert VM back to clean snapshot
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# .\scripts\windows\run_vagrant_tests.ps1
|
||||
# .\scripts\windows\run_vagrant_tests.ps1 -SkipBuild
|
||||
# .\scripts\windows\run_vagrant_tests.ps1 -SkipRevert
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Vagrant and Hyper-V enabled on the host
|
||||
# - Poetry environment set up
|
||||
# - The VM created and "clean" snapshot saved:
|
||||
# cd vagrant && vagrant up --provider=hyperv && vagrant snapshot save clean
|
||||
# Note: Inno Setup 6.7.1 is installed inside the VM by provision.ps1 — no host install needed.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipRevert,
|
||||
[string]$SnapshotName = "clean",
|
||||
[string]$LocalResultsDir = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$vmSession = $null # PSSession — closed in finally block
|
||||
$VagrantDir = Join-Path $RepoRoot "vagrant"
|
||||
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
|
||||
if (-not $LocalResultsDir) {
|
||||
$LocalResultsDir = Join-Path $RepoRoot "vagrant_test_results\$Timestamp"
|
||||
}
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "`n>>> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# ── Ensure we're in the vagrant directory ────────────────────────────────────
|
||||
|
||||
Push-Location $VagrantDir
|
||||
try {
|
||||
|
||||
# ── Step 1: Build the Windows executable (host, PyInstaller only) ────────
|
||||
# Inno Setup runs inside the VM — no host install of Inno Setup required.
|
||||
|
||||
$pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"
|
||||
$AppVersion = ""
|
||||
|
||||
if ($SkipBuild) {
|
||||
Write-Step "Skipping PyInstaller build (-SkipBuild specified)"
|
||||
if (-not (Test-Path $pyinstallerExe)) {
|
||||
Write-Host "ERROR: -SkipBuild was specified but exe not found at $pyinstallerExe" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Step "Building Windows executable (PyInstaller)"
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
Write-Host " Running PyInstaller..." -ForegroundColor Yellow
|
||||
poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec --noconfirm
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERROR: PyInstaller failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $pyinstallerExe)) {
|
||||
Write-Host "ERROR: PyInstaller output not found at $pyinstallerExe" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " PyInstaller output: $pyinstallerExe" -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Capture version to pass to Inno Setup inside the VM
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$AppVersion = (poetry version -s).Trim()
|
||||
Write-Host " App version: $AppVersion" -ForegroundColor DarkGray
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# ── Step 2: Verify VM and snapshot exist ─────────────────────────────────
|
||||
|
||||
Write-Step "Checking Vagrant VM status"
|
||||
$status = vagrant status --machine-readable 2>&1 |
|
||||
Select-String "state,running" -Quiet
|
||||
|
||||
if (-not $status) {
|
||||
Write-Host "VM is not running. Starting it..." -ForegroundColor Yellow
|
||||
vagrant up --provider=hyperv
|
||||
}
|
||||
|
||||
Write-Step "Verifying '$SnapshotName' snapshot exists"
|
||||
$snapshots = vagrant snapshot list 2>&1
|
||||
if ($snapshots -notmatch [regex]::Escape($SnapshotName)) {
|
||||
Write-Host "Snapshot '$SnapshotName' not found. Creating it now..." -ForegroundColor Yellow
|
||||
vagrant snapshot save $SnapshotName
|
||||
}
|
||||
|
||||
# ── Step 3: Restore to clean snapshot ────────────────────────────────────
|
||||
|
||||
Write-Step "Restoring VM to '$SnapshotName' snapshot"
|
||||
vagrant snapshot restore $SnapshotName
|
||||
|
||||
# ── Establish direct PSSession ────────────────────────────────────────────
|
||||
# vagrant winrm uses a subprocess whose args are subject to the Windows
|
||||
# command-line length limit (~32 KB). Large file transfers exceed that limit.
|
||||
# A direct PSSession has no such restriction and supports
|
||||
# Copy-Item -ToSession / -FromSession for binary files of any size.
|
||||
|
||||
Write-Step "Establishing PSSession to VM"
|
||||
|
||||
# Ensure host WinRM client is configured to allow Basic authentication
|
||||
# This is required for the connection to succeed when using -Authentication Basic
|
||||
if ((Get-Item WSMan:\localhost\Client\Auth\Basic).Value -ne "true") {
|
||||
Write-Host " Enabling WinRM Client Basic authentication on host..." -ForegroundColor Yellow
|
||||
Set-Item WSMan:\localhost\Client\Auth\Basic -Value $true -Force
|
||||
}
|
||||
|
||||
$remoteBuildRoot = "C:\vagrant_build"
|
||||
$remoteInstallerPath = "$remoteBuildRoot\dist\android-file-handler-setup.exe"
|
||||
$remoteResultsDir = "C:\vagrant_test_results"
|
||||
|
||||
# vagrant winrm-config reports the plaintext port (5985) used by Vagrant itself.
|
||||
# We extract the host and port, then derive the HTTPS port (typically +1).
|
||||
# If the direct IP fails, we fallback to the forwarded port on localhost.
|
||||
$winrmConfig = & vagrant winrm-config 2>$null | Out-String
|
||||
$vmHost = if ($winrmConfig -match 'HostName\s+(\S+)') { $Matches[1] } else { '127.0.0.1' }
|
||||
$vmPort = if ($winrmConfig -match 'Port\s+(\d+)') { [int]$Matches[1] + 1 } else { 5986 }
|
||||
|
||||
# If we have a direct IP but it's not reachable, fallback to the forwarded port
|
||||
if ($vmHost -ne '127.0.0.1' -and -not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
|
||||
Write-Host " Direct IP $vmHost`:$vmPort unreachable. Falling back to localhost..." -ForegroundColor Yellow
|
||||
$vmHost = '127.0.0.1'
|
||||
$vmPort = 55986 # The host-side forwarded port for 5986
|
||||
}
|
||||
|
||||
# Verify connectivity before trying PSSession
|
||||
if (-not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) {
|
||||
Write-Host "ERROR: Could not reach VM at $vmHost`:$vmPort. Ensure 'vagrant up' and provisioning completed successfully." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$vagrantCred = New-Object PSCredential 'vagrant',
|
||||
(ConvertTo-SecureString 'vagrant' -AsPlainText -Force)
|
||||
# SkipCACheck / SkipCNCheck accept the self-signed cert created by provision.ps1.
|
||||
$sessionOpts = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
|
||||
-Credential $vagrantCred -Authentication Basic `
|
||||
-UseSSL -SessionOption $sessionOpts
|
||||
|
||||
Write-Host " Connected to VM at $vmHost`:$vmPort" -ForegroundColor DarkGray
|
||||
|
||||
# ── Step 4: Transfer build assets to VM and compile installer ────────────
|
||||
|
||||
Write-Step "Transferring build assets to VM"
|
||||
|
||||
Invoke-Command -Session $vmSession -ScriptBlock {
|
||||
param($root)
|
||||
@("$root\dist", "$root\scripts\windows", "$root\icon_media") |
|
||||
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
|
||||
} -ArgumentList $remoteBuildRoot
|
||||
|
||||
$transfers = @(
|
||||
@{ L = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"; R = "$remoteBuildRoot\dist\android-file-handler-windows.exe" },
|
||||
@{ L = Join-Path $RepoRoot "scripts\windows\android-file-handler-setup.iss"; R = "$remoteBuildRoot\scripts\windows\android-file-handler-setup.iss" },
|
||||
@{ L = Join-Path $RepoRoot "LICENSE.txt"; R = "$remoteBuildRoot\LICENSE.txt" },
|
||||
@{ L = Join-Path $RepoRoot "icon_media\robot_files_256.ico"; R = "$remoteBuildRoot\icon_media\robot_files_256.ico" },
|
||||
@{ L = Join-Path $RepoRoot "vagrant\test_installer.ps1"; R = "$remoteBuildRoot\test_installer.ps1" }
|
||||
)
|
||||
foreach ($t in $transfers) {
|
||||
Copy-Item -Path $t.L -Destination $t.R -ToSession $vmSession -Force
|
||||
$sizeMB = [math]::Round((Get-Item $t.L).Length / 1MB, 1)
|
||||
Write-Host " Sent: $(Split-Path -Leaf $t.L) (${sizeMB} MB)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Unblock transferred files — Copy-Item -ToSession can mark files with a
|
||||
# Zone.Identifier ADS that execution policy may block even under -Scope Process Bypass.
|
||||
Invoke-Command -Session $vmSession -ScriptBlock {
|
||||
param($root)
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
ForEach-Object { Unblock-File -Path $_.FullName -ErrorAction SilentlyContinue }
|
||||
} -ArgumentList $remoteBuildRoot
|
||||
Write-Host " Files unblocked on VM." -ForegroundColor DarkGray
|
||||
|
||||
Write-Step "Building installer inside the VM (Inno Setup 6.7.1)"
|
||||
|
||||
Invoke-Command -Session $vmSession -ScriptBlock {
|
||||
param($buildRoot, $version)
|
||||
& 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' `
|
||||
"$buildRoot\scripts\windows\android-file-handler-setup.iss" `
|
||||
"/DMyAppVersion=$version"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
} -ArgumentList $remoteBuildRoot, $AppVersion
|
||||
|
||||
Write-Host " Installer built: $remoteInstallerPath" -ForegroundColor Green
|
||||
|
||||
# ── Step 5: Run tests inside the VM ──────────────────────────────────────
|
||||
|
||||
Write-Step "Running test suite inside the VM"
|
||||
|
||||
$testExitCode = 0
|
||||
try {
|
||||
Invoke-Command -Session $vmSession -ScriptBlock {
|
||||
param($buildRoot, $installer)
|
||||
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
|
||||
& "$buildRoot\test_installer.ps1" -InstallerPath $installer
|
||||
# Propagate the test script's exit code as output so the host can inspect it.
|
||||
$LASTEXITCODE
|
||||
} -ArgumentList $remoteBuildRoot, $remoteInstallerPath |
|
||||
ForEach-Object {
|
||||
if ($_ -is [int]) { $testExitCode = $_ }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$testExitCode = 1
|
||||
Write-Host "Test script encountered an error:" -ForegroundColor Yellow
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host " Category : $($_.CategoryInfo.Category)" -ForegroundColor Red
|
||||
Write-Host " FullError: $_" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Re-establish the PSSession if the test run left it in a non-Opened state.
|
||||
# An unhandled exception inside Invoke-Command can terminate the remote runspace.
|
||||
if ($vmSession.State -ne 'Opened') {
|
||||
Write-Host " PSSession is '$($vmSession.State)' — re-establishing for results retrieval..." -ForegroundColor Yellow
|
||||
Remove-PSSession $vmSession -ErrorAction SilentlyContinue
|
||||
$vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort `
|
||||
-Credential $vagrantCred -Authentication Basic `
|
||||
-UseSSL -SessionOption $sessionOpts
|
||||
Write-Host " Session re-established." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# ── Step 6: Retrieve results and screenshots ─────────────────────────────
|
||||
|
||||
Write-Step "Retrieving test results and screenshots"
|
||||
|
||||
if (-not (Test-Path $LocalResultsDir)) {
|
||||
New-Item -ItemType Directory -Path $LocalResultsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
$hasResults = Invoke-Command -Session $vmSession -ScriptBlock {
|
||||
param($dir) Test-Path $dir
|
||||
} -ArgumentList $remoteResultsDir
|
||||
|
||||
if ($hasResults) {
|
||||
Copy-Item -Path "$remoteResultsDir\*" -Destination $LocalResultsDir `
|
||||
-FromSession $vmSession -Recurse -Force
|
||||
Write-Host " Retrieved results from $remoteResultsDir" -ForegroundColor DarkGray
|
||||
}
|
||||
else {
|
||||
Write-Host " No results directory found on VM." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " Warning: Could not retrieve some result files: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ── Step 7: Print local summary ──────────────────────────────────────────
|
||||
|
||||
Write-Step "Test Results"
|
||||
|
||||
$csvPath = Join-Path $LocalResultsDir "test_results.csv"
|
||||
if (Test-Path $csvPath) {
|
||||
$results = Import-Csv $csvPath
|
||||
$results | Format-Table -AutoSize
|
||||
|
||||
$passCount = @($results | Where-Object { $_.Status -eq "PASS" }).Count
|
||||
$failCount = @($results | Where-Object { $_.Status -eq "FAIL" }).Count
|
||||
Write-Host " Passed: $passCount | Failed: $failCount | Total: $($passCount + $failCount)" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" })
|
||||
}
|
||||
else {
|
||||
Write-Host " No results CSV found. Check VM output above." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n Results directory: $LocalResultsDir" -ForegroundColor White
|
||||
|
||||
# List screenshots
|
||||
$screenshots = Get-ChildItem $LocalResultsDir -Filter "*.png" -ErrorAction SilentlyContinue
|
||||
if ($screenshots) {
|
||||
Write-Host " Screenshots:" -ForegroundColor White
|
||||
foreach ($shot in $screenshots) {
|
||||
Write-Host " - $($shot.FullName)" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 8: Revert to clean snapshot ─────────────────────────────────────
|
||||
|
||||
if (-not $SkipRevert) {
|
||||
Write-Step "Reverting VM to '$SnapshotName' snapshot"
|
||||
vagrant snapshot restore $SnapshotName
|
||||
Write-Host "VM reverted to clean state." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "`n -SkipRevert specified, VM left in current state." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ── Exit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
if ($testExitCode -ne 0) {
|
||||
Write-Host "One or more tests FAILED." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
else {
|
||||
Write-Host "All tests PASSED." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
}
|
||||
finally {
|
||||
if ($vmSession) { Remove-PSSession $vmSession -ErrorAction SilentlyContinue }
|
||||
Pop-Location
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
Core Package
|
||||
Core functionality and business logic components.
|
||||
"""
|
||||
"""
|
||||
|
||||
+10
-8
@@ -13,11 +13,13 @@ from .platform_tools import get_adb_binary_path
|
||||
|
||||
class ADBCommandRunner:
|
||||
"""Handles ADB command execution and device communication."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.current_process: Optional[subprocess.Popen] = None
|
||||
|
||||
def run_adb_command(self, args: list, capture_output: bool = True) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]:
|
||||
|
||||
def run_adb_command(
|
||||
self, args: list, capture_output: bool = True
|
||||
) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]:
|
||||
"""Run an ADB command and return output."""
|
||||
cmd = [get_adb_binary_path()] + args
|
||||
try:
|
||||
@@ -34,7 +36,7 @@ class ADBCommandRunner:
|
||||
return None, str(e), -1
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def check_device(self) -> Optional[str]:
|
||||
"""Check if an Android device is connected."""
|
||||
result = self.run_adb_command(["devices"], capture_output=True)
|
||||
@@ -46,7 +48,7 @@ class ADBCommandRunner:
|
||||
if line.endswith("\tdevice"):
|
||||
return line.split()[0]
|
||||
return None
|
||||
|
||||
|
||||
def parse_progress(self, text_line: str) -> Optional[int]:
|
||||
"""Parse progress percentage from ADB output.
|
||||
|
||||
@@ -121,7 +123,7 @@ class ADBCommandRunner:
|
||||
return 100
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def cancel_current_operation(self) -> bool:
|
||||
"""Cancel the current ADB operation.
|
||||
|
||||
@@ -149,9 +151,9 @@ class ADBCommandRunner:
|
||||
# Force kill if it doesn't terminate gracefully
|
||||
self.current_process.kill()
|
||||
self.current_process.wait()
|
||||
|
||||
|
||||
self.current_process = None
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
return False
|
||||
|
||||
+165
-117
@@ -12,10 +12,10 @@ from typing import Optional, Tuple, Callable
|
||||
|
||||
# Import our modular components
|
||||
from .platform_tools import (
|
||||
ensure_platform_tools_in_user_dir,
|
||||
get_adb_binary_path,
|
||||
ensure_platform_tools_in_user_dir,
|
||||
get_adb_binary_path,
|
||||
is_adb_available,
|
||||
download_and_extract_adb
|
||||
download_and_extract_adb,
|
||||
)
|
||||
from .platform_utils import get_platform_tools_directory, get_platform_type
|
||||
from .file_transfer import ADBFileTransfer
|
||||
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ADBManager:
|
||||
"""Main interface for ADB operations, device management, and file transfers."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ADB manager."""
|
||||
self.command_runner = ADBCommandRunner()
|
||||
@@ -53,19 +53,19 @@ class ADBManager:
|
||||
self.deduplicator = FileDeduplicator()
|
||||
except Exception:
|
||||
self.deduplicator = None
|
||||
|
||||
|
||||
# Progress and status callbacks for compatibility
|
||||
self.progress_callback = None
|
||||
self.status_callback = None
|
||||
|
||||
|
||||
# Transfer progress tracking
|
||||
self.transfer_progress = {
|
||||
'current_file': 0,
|
||||
'total_files': 0,
|
||||
'transferred_bytes': 0,
|
||||
'total_bytes': 0
|
||||
"current_file": 0,
|
||||
"total_files": 0,
|
||||
"transferred_bytes": 0,
|
||||
"total_bytes": 0,
|
||||
}
|
||||
|
||||
|
||||
# Lazily initialize ADB binary path to reduce side effects during init
|
||||
self._adb_path: Optional[str] = None
|
||||
|
||||
@@ -81,68 +81,70 @@ class ADBManager:
|
||||
@adb_path.setter
|
||||
def adb_path(self, value: Optional[str]) -> None:
|
||||
self._adb_path = value
|
||||
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if ADB is available."""
|
||||
return is_adb_available()
|
||||
|
||||
|
||||
def ensure_adb_installed(self) -> bool:
|
||||
"""Ensure ADB is installed and available."""
|
||||
try:
|
||||
if self.is_available():
|
||||
return True
|
||||
|
||||
|
||||
# Try to download and install ADB
|
||||
self.adb_path = ensure_platform_tools_in_user_dir()
|
||||
if self.adb_path and os.path.exists(self.adb_path):
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error ensuring ADB installation: {e}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_devices(self) -> list[str]:
|
||||
"""Get list of connected Android devices."""
|
||||
try:
|
||||
args = ["devices"]
|
||||
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
|
||||
|
||||
|
||||
if returncode != 0:
|
||||
return []
|
||||
|
||||
|
||||
devices = []
|
||||
for line in stdout.split('\n')[1:]: # Skip first line "List of devices attached"
|
||||
for line in stdout.split("\n")[
|
||||
1:
|
||||
]: # Skip first line "List of devices attached"
|
||||
line = line.strip()
|
||||
if line and '\t' in line:
|
||||
device_id = line.split('\t')[0]
|
||||
if line and "\t" in line:
|
||||
device_id = line.split("\t")[0]
|
||||
if device_id:
|
||||
devices.append(device_id)
|
||||
|
||||
|
||||
return devices
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def is_device_connected(self, device_id: Optional[str] = None) -> bool:
|
||||
"""Check if a specific device is connected."""
|
||||
devices = self.get_devices()
|
||||
if not devices:
|
||||
return False
|
||||
|
||||
|
||||
if device_id:
|
||||
return device_id in devices
|
||||
else:
|
||||
# Check if any device is connected
|
||||
return len(devices) > 0
|
||||
|
||||
|
||||
def select_device(self, device_id: str) -> None:
|
||||
"""Select a specific device for operations."""
|
||||
self.selected_device = device_id
|
||||
|
||||
|
||||
def get_selected_device(self) -> Optional[str]:
|
||||
"""Get the currently selected device."""
|
||||
return self.selected_device
|
||||
|
||||
|
||||
def list_files(self, path: str, device_id: Optional[str] = None) -> list[dict]:
|
||||
"""List files in the specified path on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
@@ -174,58 +176,64 @@ class ADBManager:
|
||||
return []
|
||||
|
||||
args = device_args + ["shell", "ls", "-la", sanitized_path]
|
||||
|
||||
|
||||
try:
|
||||
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
|
||||
if returncode != 0:
|
||||
return []
|
||||
|
||||
|
||||
files = []
|
||||
for line in stdout.split('\n'):
|
||||
for line in stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('total '):
|
||||
if not line or line.startswith("total "):
|
||||
continue
|
||||
|
||||
|
||||
# Parse ls -la output
|
||||
parts = line.split()
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
|
||||
|
||||
permissions = parts[0]
|
||||
size_str = parts[4]
|
||||
|
||||
|
||||
# Join filename parts (handles spaces in filenames)
|
||||
name = ' '.join(parts[8:])
|
||||
|
||||
name = " ".join(parts[8:])
|
||||
|
||||
# Skip current and parent directory entries
|
||||
if name in ['.', '..']:
|
||||
if name in [".", ".."]:
|
||||
continue
|
||||
|
||||
file_type = 'folder' if permissions.startswith('d') else 'file'
|
||||
|
||||
|
||||
file_type = "folder" if permissions.startswith("d") else "file"
|
||||
|
||||
try:
|
||||
size = int(size_str) if file_type == 'file' else 0
|
||||
size = int(size_str) if file_type == "file" else 0
|
||||
except ValueError:
|
||||
size = 0
|
||||
|
||||
|
||||
# Combine date and time parts
|
||||
modified = ' '.join(parts[5:8]) if len(parts) >= 8 else ''
|
||||
|
||||
files.append({
|
||||
'name': name,
|
||||
'type': file_type,
|
||||
'size': size,
|
||||
'permissions': permissions,
|
||||
'modified': modified
|
||||
})
|
||||
|
||||
modified = " ".join(parts[5:8]) if len(parts) >= 8 else ""
|
||||
|
||||
files.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": file_type,
|
||||
"size": size,
|
||||
"permissions": permissions,
|
||||
"modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
return files
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def pull_file(self, remote_path: str, local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
|
||||
def pull_file(
|
||||
self,
|
||||
remote_path: str,
|
||||
local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Pull a file from device to local system."""
|
||||
try:
|
||||
# Create local directory if it doesn't exist
|
||||
@@ -236,7 +244,7 @@ class ADBManager:
|
||||
except Exception:
|
||||
# Normalize error message for tests
|
||||
return False, "Failed to pull file"
|
||||
|
||||
|
||||
# Use our file transfer module
|
||||
success = self.file_transfer.pull_file(remote_path, local_path)
|
||||
if success:
|
||||
@@ -245,10 +253,14 @@ class ADBManager:
|
||||
return False, "Failed to pull file"
|
||||
except Exception as e:
|
||||
return False, "Failed to pull file"
|
||||
|
||||
def pull_folder(self, remote_path: str, local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
|
||||
def pull_folder(
|
||||
self,
|
||||
remote_path: str,
|
||||
local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Pull a folder from device to local system."""
|
||||
try:
|
||||
# Create local directory
|
||||
@@ -256,7 +268,7 @@ class ADBManager:
|
||||
os.makedirs(local_path, exist_ok=True)
|
||||
except Exception:
|
||||
return False, "Failed to pull folder"
|
||||
|
||||
|
||||
# Use our file transfer module
|
||||
success = self.file_transfer.pull_folder(remote_path, local_path)
|
||||
if success:
|
||||
@@ -265,15 +277,19 @@ class ADBManager:
|
||||
return False, "Failed to pull folder"
|
||||
except Exception as e:
|
||||
return False, "Failed to pull folder"
|
||||
|
||||
def push_file(self, local_path: str, remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
|
||||
def push_file(
|
||||
self,
|
||||
local_path: str,
|
||||
remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Push a file from local system to device."""
|
||||
try:
|
||||
if not os.path.exists(local_path):
|
||||
return False, f"Local file not found: {local_path}"
|
||||
|
||||
|
||||
# Use our file transfer module
|
||||
success = self.file_transfer.push_file(local_path, remote_path)
|
||||
if success:
|
||||
@@ -282,15 +298,19 @@ class ADBManager:
|
||||
return False, "Failed to push file"
|
||||
except Exception as e:
|
||||
return False, f"Error pushing file: {str(e)}"
|
||||
|
||||
def push_folder(self, local_path: str, remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
|
||||
def push_folder(
|
||||
self,
|
||||
local_path: str,
|
||||
remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Push a folder from local system to device."""
|
||||
try:
|
||||
if not os.path.exists(local_path):
|
||||
return False, f"Local folder not found: {local_path}"
|
||||
|
||||
|
||||
# Use our file transfer module
|
||||
success = self.file_transfer.push_folder(local_path, remote_path)
|
||||
if success:
|
||||
@@ -299,8 +319,10 @@ class ADBManager:
|
||||
return False, "Failed to push folder"
|
||||
except Exception as e:
|
||||
return False, f"Error pushing folder: {str(e)}"
|
||||
|
||||
def delete_file(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
|
||||
def delete_file(
|
||||
self, remote_path: str, device_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""Delete a file on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
try:
|
||||
@@ -328,7 +350,9 @@ class ADBManager:
|
||||
except Exception as e:
|
||||
return False, f"Error deleting file: {str(e)}"
|
||||
|
||||
def create_folder(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
def create_folder(
|
||||
self, remote_path: str, device_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""Create a folder on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
try:
|
||||
@@ -356,7 +380,9 @@ class ADBManager:
|
||||
except Exception as e:
|
||||
return False, f"Error creating folder: {str(e)}"
|
||||
|
||||
def delete_folder(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
def delete_folder(
|
||||
self, remote_path: str, device_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""Delete a folder on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
try:
|
||||
@@ -384,7 +410,9 @@ class ADBManager:
|
||||
except Exception as e:
|
||||
return False, f"Error deleting folder: {str(e)}"
|
||||
|
||||
def move_item(self, old_path: str, new_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
def move_item(
|
||||
self, old_path: str, new_path: str, device_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""Move/rename a file or folder on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
try:
|
||||
@@ -413,7 +441,9 @@ class ADBManager:
|
||||
except Exception as e:
|
||||
return False, f"Error moving item: {str(e)}"
|
||||
|
||||
def get_file_info(self, remote_path: str, device_id: Optional[str] = None) -> Optional[dict]:
|
||||
def get_file_info(
|
||||
self, remote_path: str, device_id: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
"""Get information about a file or folder on the device."""
|
||||
# Sanitize inputs to prevent command injection
|
||||
try:
|
||||
@@ -443,50 +473,54 @@ class ADBManager:
|
||||
return None
|
||||
|
||||
args = device_args + ["shell", "ls", "-la", sanitized_path]
|
||||
|
||||
|
||||
try:
|
||||
stdout, stderr, returncode = self.command_runner.run_adb_command(args)
|
||||
if returncode != 0:
|
||||
return None
|
||||
|
||||
lines = stdout.strip().split('\n')
|
||||
|
||||
lines = stdout.strip().split("\n")
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
|
||||
# Parse the first non-empty line (should be the file info)
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('total '):
|
||||
if line and not line.startswith("total "):
|
||||
parts = line.split()
|
||||
if len(parts) >= 9:
|
||||
permissions = parts[0]
|
||||
size_str = parts[4]
|
||||
name = ' '.join(parts[8:])
|
||||
|
||||
file_type = 'folder' if permissions.startswith('d') else 'file'
|
||||
|
||||
name = " ".join(parts[8:])
|
||||
|
||||
file_type = "folder" if permissions.startswith("d") else "file"
|
||||
|
||||
try:
|
||||
size = int(size_str) if file_type == 'file' else 0
|
||||
size = int(size_str) if file_type == "file" else 0
|
||||
except ValueError:
|
||||
size = 0
|
||||
|
||||
modified = ' '.join(parts[5:8]) if len(parts) >= 8 else ''
|
||||
|
||||
|
||||
modified = " ".join(parts[5:8]) if len(parts) >= 8 else ""
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'type': file_type,
|
||||
'size': size,
|
||||
'permissions': permissions,
|
||||
'modified': modified
|
||||
"name": name,
|
||||
"type": file_type,
|
||||
"size": size,
|
||||
"permissions": permissions,
|
||||
"modified": modified,
|
||||
}
|
||||
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def pull_folder_with_dedup(self, remote_path: str, local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, Optional[dict]]:
|
||||
|
||||
def pull_folder_with_dedup(
|
||||
self,
|
||||
remote_path: str,
|
||||
local_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[dict]]:
|
||||
"""Pull a folder from device with deduplication support.
|
||||
|
||||
Args:
|
||||
@@ -500,13 +534,19 @@ class ADBManager:
|
||||
"""
|
||||
# For now, just call the regular pull_folder
|
||||
# TODO: Implement actual deduplication logic
|
||||
success, message = self.pull_folder(remote_path, local_path, progress_callback, device_id)
|
||||
stats = {'message': message} if success else None
|
||||
success, message = self.pull_folder(
|
||||
remote_path, local_path, progress_callback, device_id
|
||||
)
|
||||
stats = {"message": message} if success else None
|
||||
return success, stats
|
||||
|
||||
def push_folder_with_dedup(self, local_path: str, remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None) -> Tuple[bool, Optional[dict]]:
|
||||
def push_folder_with_dedup(
|
||||
self,
|
||||
local_path: str,
|
||||
remote_path: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
device_id: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[dict]]:
|
||||
"""Push a folder to device with deduplication support.
|
||||
|
||||
Args:
|
||||
@@ -520,11 +560,17 @@ class ADBManager:
|
||||
"""
|
||||
# For now, just call the regular push_folder
|
||||
# TODO: Implement actual deduplication logic
|
||||
success, message = self.push_folder(local_path, remote_path, progress_callback, device_id)
|
||||
stats = {'message': message} if success else None
|
||||
success, message = self.push_folder(
|
||||
local_path, remote_path, progress_callback, device_id
|
||||
)
|
||||
stats = {"message": message} if success else None
|
||||
return success, stats
|
||||
|
||||
def deduplicate_files(self, folder_path: str, progress_callback: Optional[Callable[[str], None]] = None) -> Tuple[int, list]:
|
||||
def deduplicate_files(
|
||||
self,
|
||||
folder_path: str,
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[int, list]:
|
||||
"""Find and optionally remove duplicate files in a folder."""
|
||||
deduplicator = FileDeduplicator()
|
||||
|
||||
@@ -564,15 +610,15 @@ class ADBManager:
|
||||
self.status_callback(message)
|
||||
|
||||
def _update_transfer_progress(self, current_file: int, total_files: int) -> None:
|
||||
self.transfer_progress['current_file'] = current_file
|
||||
self.transfer_progress['total_files'] = total_files
|
||||
self.transfer_progress["current_file"] = current_file
|
||||
self.transfer_progress["total_files"] = total_files
|
||||
if self.status_callback:
|
||||
self.status_callback(f"TRANSFER_PROGRESS:{current_file}:{total_files}")
|
||||
|
||||
def _reset_transfer_progress(self) -> None:
|
||||
self.transfer_progress['current_file'] = 0
|
||||
self.transfer_progress['total_files'] = 0
|
||||
self.transfer_progress['files_to_transfer'] = 0
|
||||
self.transfer_progress["current_file"] = 0
|
||||
self.transfer_progress["total_files"] = 0
|
||||
self.transfer_progress["files_to_transfer"] = 0
|
||||
|
||||
def check_local_disk_space(self) -> bool:
|
||||
tools_dir = get_platform_tools_directory()
|
||||
@@ -589,7 +635,9 @@ class ADBManager:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
return p.stdout, p.stderr, p.returncode
|
||||
else:
|
||||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
p = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
||||
)
|
||||
self.current_process = p
|
||||
return p
|
||||
except Exception as e:
|
||||
@@ -598,7 +646,7 @@ class ADBManager:
|
||||
return None
|
||||
|
||||
def check_device(self) -> Optional[str]:
|
||||
out, err, rc = self.run_adb_command(['devices'], capture_output=True)
|
||||
out, err, rc = self.run_adb_command(["devices"], capture_output=True)
|
||||
if rc != 0 or not out:
|
||||
return None
|
||||
for line in str(out).splitlines():
|
||||
|
||||
+68
-35
@@ -22,7 +22,7 @@ except ImportError:
|
||||
|
||||
class ADBFileTransfer(ProgressTracker):
|
||||
"""Handles ADB file transfer operations with progress tracking."""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
ProgressTracker.__init__(self)
|
||||
# Composition: allow tests to patch ADBCommandRunner constructor
|
||||
@@ -43,7 +43,7 @@ class ADBFileTransfer(ProgressTracker):
|
||||
return bool(result) if result is not None else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_windows_root_path(self, path: str, operation: str):
|
||||
"""Validate Windows root drive paths and raise on invalid usage.
|
||||
|
||||
@@ -54,13 +54,17 @@ class ADBFileTransfer(ProgressTracker):
|
||||
"""
|
||||
# Detect bare drive root (e.g., "C:" or "D:")
|
||||
path_str = str(path).strip()
|
||||
if len(path_str) >= 2 and path_str[1] == ":" and (len(path_str) == 2 or path_str.endswith(("/", "\\"))):
|
||||
if (
|
||||
len(path_str) >= 2
|
||||
and path_str[1] == ":"
|
||||
and (len(path_str) == 2 or path_str.endswith(("/", "\\")))
|
||||
):
|
||||
op_lower = operation.lower()
|
||||
if "push" in op_lower:
|
||||
raise ValueError("Cannot push to Windows root")
|
||||
if "pull" in op_lower:
|
||||
raise ValueError("Cannot pull from Windows root")
|
||||
|
||||
|
||||
def pull_file(self, remote_file_path: str, local_file_path: str) -> bool:
|
||||
"""Pull a single file from Android device to local machine."""
|
||||
# Sanitize paths to prevent injection and traversal
|
||||
@@ -86,9 +90,11 @@ class ADBFileTransfer(ProgressTracker):
|
||||
self.update_status(f"Failed to create local directory: {e}")
|
||||
pass
|
||||
|
||||
result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local])
|
||||
result = self.runner.run_adb_command(
|
||||
["pull", sanitized_remote, sanitized_local]
|
||||
)
|
||||
return self._is_command_success(result)
|
||||
|
||||
|
||||
def push_file(self, local_file_path: str, remote_file_path: str) -> bool:
|
||||
"""Push a single file from local machine to Android device."""
|
||||
# Sanitize paths to prevent injection and traversal
|
||||
@@ -102,9 +108,11 @@ class ADBFileTransfer(ProgressTracker):
|
||||
if not os.path.exists(sanitized_local) or not os.path.isfile(sanitized_local):
|
||||
return False
|
||||
|
||||
result = self.runner.run_adb_command(['push', sanitized_local, sanitized_remote])
|
||||
result = self.runner.run_adb_command(
|
||||
["push", sanitized_local, sanitized_remote]
|
||||
)
|
||||
return self._is_command_success(result)
|
||||
|
||||
|
||||
def pull_folder(self, remote_path: str, local_path: str) -> bool:
|
||||
"""Pull files from Android device to local machine."""
|
||||
# Sanitize paths to prevent injection and traversal
|
||||
@@ -123,9 +131,11 @@ class ADBFileTransfer(ProgressTracker):
|
||||
except Exception:
|
||||
# Ignore directory creation failures for test environment
|
||||
pass
|
||||
result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local])
|
||||
result = self.runner.run_adb_command(
|
||||
["pull", sanitized_remote, sanitized_local]
|
||||
)
|
||||
return self._is_command_success(result)
|
||||
|
||||
|
||||
def push_folder(self, local_path: str, remote_path: str) -> bool:
|
||||
"""Push files from local machine to Android device."""
|
||||
# Sanitize paths to prevent injection and traversal
|
||||
@@ -138,9 +148,11 @@ class ADBFileTransfer(ProgressTracker):
|
||||
|
||||
if not os.path.exists(sanitized_local) or not os.path.isdir(sanitized_local):
|
||||
return False
|
||||
result = self.runner.run_adb_command(['push', sanitized_local, sanitized_remote])
|
||||
result = self.runner.run_adb_command(
|
||||
["push", sanitized_local, sanitized_remote]
|
||||
)
|
||||
return self._is_command_success(result)
|
||||
|
||||
|
||||
def _execute_transfer_command(self, cmd: list, operation_name: str) -> bool:
|
||||
"""Execute a single file transfer command with progress tracking."""
|
||||
try:
|
||||
@@ -165,11 +177,11 @@ class ADBFileTransfer(ProgressTracker):
|
||||
for line in proc.stdout:
|
||||
line_count += 1
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# Check for file completion
|
||||
if "1 file" in line and ("pulled" in line or "pushed" in line):
|
||||
self.update_transfer_progress(1, 1)
|
||||
|
||||
|
||||
pct = self.parse_progress(line)
|
||||
|
||||
if pct is not None:
|
||||
@@ -179,9 +191,14 @@ class ADBFileTransfer(ProgressTracker):
|
||||
last_update_time = current_time
|
||||
else:
|
||||
# For single files, use simpler progress estimation
|
||||
estimated = TransferProgressEstimator.estimate_progress_from_time(
|
||||
start_time, last_progress, elapsed_threshold=1.0,
|
||||
max_increment=20, max_progress=90
|
||||
estimated = (
|
||||
TransferProgressEstimator.estimate_progress_from_time(
|
||||
start_time,
|
||||
last_progress,
|
||||
elapsed_threshold=1.0,
|
||||
max_increment=20,
|
||||
max_progress=90,
|
||||
)
|
||||
)
|
||||
if estimated is not None:
|
||||
self.update_progress(estimated)
|
||||
@@ -213,9 +230,10 @@ class ADBFileTransfer(ProgressTracker):
|
||||
self.update_status(f"{operation_name} error: {e}")
|
||||
self.current_process = None
|
||||
return False
|
||||
|
||||
def _execute_folder_transfer_command(self, cmd: list, operation_name: str,
|
||||
completion_verb: str) -> bool:
|
||||
|
||||
def _execute_folder_transfer_command(
|
||||
self, cmd: list, operation_name: str, completion_verb: str
|
||||
) -> bool:
|
||||
"""Execute a folder transfer command with progress tracking."""
|
||||
try:
|
||||
self.update_progress(0)
|
||||
@@ -240,22 +258,28 @@ class ADBFileTransfer(ProgressTracker):
|
||||
for line in proc.stdout:
|
||||
line_count += 1
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# Check for file completion patterns in ADB output
|
||||
if f": 1 file {completion_verb}" in line or f"files {completion_verb}" in line:
|
||||
if (
|
||||
f": 1 file {completion_verb}" in line
|
||||
or f"files {completion_verb}" in line
|
||||
):
|
||||
# Extract number of files from the line
|
||||
if f"1 file {completion_verb}" in line:
|
||||
files_transferred += 1
|
||||
else:
|
||||
# Parse "X files pulled/pushed" pattern
|
||||
match = re.search(rf'(\d+) files {completion_verb}', line)
|
||||
match = re.search(rf"(\d+) files {completion_verb}", line)
|
||||
if match:
|
||||
files_transferred = int(match.group(1))
|
||||
|
||||
|
||||
# Update file transfer progress
|
||||
if self.transfer_progress['files_to_transfer'] > 0:
|
||||
self.update_transfer_progress(files_transferred, self.transfer_progress['files_to_transfer'])
|
||||
|
||||
if self.transfer_progress["files_to_transfer"] > 0:
|
||||
self.update_transfer_progress(
|
||||
files_transferred,
|
||||
self.transfer_progress["files_to_transfer"],
|
||||
)
|
||||
|
||||
pct = self.parse_progress(line)
|
||||
|
||||
if pct is not None:
|
||||
@@ -269,9 +293,13 @@ class ADBFileTransfer(ProgressTracker):
|
||||
should_update = False
|
||||
new_progress = last_progress
|
||||
|
||||
if operation_name == "Transfer": # Pull operation - more complex logic
|
||||
estimated = TransferProgressEstimator.estimate_complex_progress(
|
||||
line_count, elapsed_time, last_progress
|
||||
if (
|
||||
operation_name == "Transfer"
|
||||
): # Pull operation - more complex logic
|
||||
estimated = (
|
||||
TransferProgressEstimator.estimate_complex_progress(
|
||||
line_count, elapsed_time, last_progress
|
||||
)
|
||||
)
|
||||
if estimated is not None and time_since_last_update >= 2.0:
|
||||
new_progress = estimated
|
||||
@@ -281,9 +309,14 @@ class ADBFileTransfer(ProgressTracker):
|
||||
new_progress = min(last_progress + increment, 90)
|
||||
should_update = True
|
||||
else: # Push operation - simpler logic
|
||||
estimated = TransferProgressEstimator.estimate_progress_from_time(
|
||||
start_time, last_progress, elapsed_threshold=1.0,
|
||||
max_increment=20, max_progress=90
|
||||
estimated = (
|
||||
TransferProgressEstimator.estimate_progress_from_time(
|
||||
start_time,
|
||||
last_progress,
|
||||
elapsed_threshold=1.0,
|
||||
max_increment=20,
|
||||
max_progress=90,
|
||||
)
|
||||
)
|
||||
if estimated is not None:
|
||||
new_progress = estimated
|
||||
@@ -318,7 +351,7 @@ class ADBFileTransfer(ProgressTracker):
|
||||
self.update_status(f"{operation_name} error: {e}")
|
||||
self.current_process = None
|
||||
return False
|
||||
|
||||
|
||||
def cancel_transfer(self) -> bool:
|
||||
"""Cancel the current transfer operation."""
|
||||
# Local implementation to avoid depending on ADBCommandRunner inheritance
|
||||
@@ -341,4 +374,4 @@ class ADBFileTransfer(ProgressTracker):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
return False
|
||||
|
||||
+33
-11
@@ -11,8 +11,12 @@ import shutil
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from .platform_utils import get_adb_binary_name, get_platform_tools_directory, is_windows, is_linux
|
||||
|
||||
from .platform_utils import (
|
||||
get_adb_binary_name,
|
||||
get_platform_tools_directory,
|
||||
is_windows,
|
||||
is_linux,
|
||||
)
|
||||
|
||||
# Constants for download URLs
|
||||
ADB_WIN_ZIP_URL = (
|
||||
@@ -43,11 +47,15 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
|
||||
|
||||
# Determine base data dir
|
||||
if user_data_dir:
|
||||
data_root = os.path.join(user_data_dir("android-file-handler"), "platform-tools")
|
||||
data_root = os.path.join(
|
||||
user_data_dir("android-file-handler"), "platform-tools"
|
||||
)
|
||||
else:
|
||||
# Fallback: use home-based location
|
||||
home = os.path.expanduser("~")
|
||||
data_root = os.path.join(home, ".local", "share", "android-file-handler", "platform-tools")
|
||||
data_root = os.path.join(
|
||||
home, ".local", "share", "android-file-handler", "platform-tools"
|
||||
)
|
||||
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
|
||||
@@ -102,8 +110,12 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
|
||||
raise RuntimeError(f"Redirect to untrusted domain: {resp.url}")
|
||||
|
||||
# Check Content-Type to ensure we're getting a zip file
|
||||
content_type = resp.headers.get('Content-Type', '')
|
||||
if content_type and 'zip' not in content_type.lower() and 'octet-stream' not in content_type.lower():
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
if (
|
||||
content_type
|
||||
and "zip" not in content_type.lower()
|
||||
and "octet-stream" not in content_type.lower()
|
||||
):
|
||||
raise RuntimeError(f"Unexpected content type: {content_type}")
|
||||
|
||||
zip_path = os.path.join(tmp_dir, "platform-tools.zip")
|
||||
@@ -137,10 +149,17 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
|
||||
tmp_dir_normalized = os.path.normpath(tmp_dir)
|
||||
|
||||
# Ensure tmp_dir has a trailing separator for proper prefix matching
|
||||
tmp_dir_with_sep = tmp_dir_normalized if tmp_dir_normalized.endswith(os.sep) else tmp_dir_normalized + os.sep
|
||||
tmp_dir_with_sep = (
|
||||
tmp_dir_normalized
|
||||
if tmp_dir_normalized.endswith(os.sep)
|
||||
else tmp_dir_normalized + os.sep
|
||||
)
|
||||
|
||||
# Check if normalized path is within tmp_dir
|
||||
if not (normalized.startswith(tmp_dir_with_sep) or normalized == tmp_dir_normalized):
|
||||
if not (
|
||||
normalized.startswith(tmp_dir_with_sep)
|
||||
or normalized == tmp_dir_normalized
|
||||
):
|
||||
raise RuntimeError(f"Zip contains path traversal: {info.filename}")
|
||||
|
||||
zf.extractall(tmp_dir)
|
||||
@@ -151,7 +170,9 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") ->
|
||||
# try to find a platform-tools directory inside temp
|
||||
for entry in os.listdir(tmp_dir):
|
||||
candidate = os.path.join(tmp_dir, entry)
|
||||
if os.path.isdir(candidate) and entry.lower().startswith("platform-tools"):
|
||||
if os.path.isdir(candidate) and entry.lower().startswith(
|
||||
"platform-tools"
|
||||
):
|
||||
extracted_dir = candidate
|
||||
break
|
||||
|
||||
@@ -212,7 +233,8 @@ def get_adb_binary_path() -> str:
|
||||
# Legacy pathway: some tests patch ensure_platform_tools_in_user_dir via adb_manager module
|
||||
try:
|
||||
from . import adb_manager as _adb_manager # type: ignore
|
||||
if hasattr(_adb_manager, 'ensure_platform_tools_in_user_dir'):
|
||||
|
||||
if hasattr(_adb_manager, "ensure_platform_tools_in_user_dir"):
|
||||
adb_path = _adb_manager.ensure_platform_tools_in_user_dir()
|
||||
if adb_path and os.path.isfile(adb_path):
|
||||
return adb_path
|
||||
@@ -248,4 +270,4 @@ def download_and_extract_adb() -> bool:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
|
||||
def get_executable_directory() -> str:
|
||||
"""Get the directory containing the executable or script."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as executable (PyInstaller, cx_Freeze, etc.)
|
||||
return os.path.dirname(sys.executable)
|
||||
else:
|
||||
@@ -21,14 +21,14 @@ def get_executable_directory() -> str:
|
||||
def get_platform_tools_directory() -> str:
|
||||
"""Get platform-tools directory."""
|
||||
base_dir = get_executable_directory()
|
||||
|
||||
|
||||
# Check if we're in development mode (running from src/ directory)
|
||||
if not getattr(sys, 'frozen', False):
|
||||
if not getattr(sys, "frozen", False):
|
||||
# Running as script - check if we're in src/ directory or subdirectory
|
||||
if base_dir.endswith('src'):
|
||||
if base_dir.endswith("src"):
|
||||
# Already in src directory - place platform-tools here
|
||||
return os.path.join(base_dir, "platform-tools")
|
||||
elif base_dir.endswith('gui') or os.path.basename(base_dir) in ['gui']:
|
||||
elif base_dir.endswith("gui") or os.path.basename(base_dir) in ["gui"]:
|
||||
# In src/gui subdirectory - go up one level to src
|
||||
src_dir = os.path.dirname(base_dir)
|
||||
return os.path.join(src_dir, "platform-tools")
|
||||
@@ -42,7 +42,7 @@ def get_platform_tools_directory() -> str:
|
||||
else:
|
||||
# Fallback to current directory
|
||||
return os.path.join(base_dir, "src", "platform-tools")
|
||||
|
||||
|
||||
# Running as executable - use directory next to binary
|
||||
return os.path.join(base_dir, "platform-tools")
|
||||
|
||||
@@ -72,4 +72,4 @@ def is_linux() -> bool:
|
||||
|
||||
def is_macos() -> bool:
|
||||
"""Check if running on macOS."""
|
||||
return sys.platform.startswith("darwin")
|
||||
return sys.platform.startswith("darwin")
|
||||
|
||||
@@ -22,9 +22,9 @@ class ProgressTracker:
|
||||
self.current_speed: float = 0.0
|
||||
self.estimated_time_remaining: int = 0
|
||||
self.transfer_progress: Dict[str, int] = {
|
||||
'current_file': 0,
|
||||
'total_files': 0,
|
||||
'files_to_transfer': 0
|
||||
"current_file": 0,
|
||||
"total_files": 0,
|
||||
"files_to_transfer": 0,
|
||||
}
|
||||
|
||||
def set_progress_callback(self, callback: Callable[[int], None]) -> None:
|
||||
@@ -84,8 +84,8 @@ class ProgressTracker:
|
||||
|
||||
def update_transfer_progress(self, current_file: int, total_files: int) -> None:
|
||||
"""Update transfer progress for file counting."""
|
||||
self.transfer_progress['current_file'] = current_file
|
||||
self.transfer_progress['total_files'] = total_files
|
||||
self.transfer_progress["current_file"] = current_file
|
||||
self.transfer_progress["total_files"] = total_files
|
||||
# Send progress update through status callback with special format
|
||||
progress_message = f"TRANSFER_PROGRESS:{current_file}:{total_files}"
|
||||
if self.status_callback:
|
||||
@@ -94,14 +94,14 @@ class ProgressTracker:
|
||||
def reset_transfer_progress(self) -> None:
|
||||
"""Reset transfer progress counters."""
|
||||
self.transfer_progress = {
|
||||
'current_file': 0,
|
||||
'total_files': 0,
|
||||
'files_to_transfer': 0
|
||||
"current_file": 0,
|
||||
"total_files": 0,
|
||||
"files_to_transfer": 0,
|
||||
}
|
||||
|
||||
def set_files_to_transfer(self, count: int) -> None:
|
||||
"""Set the total number of files to transfer."""
|
||||
self.transfer_progress['files_to_transfer'] = count
|
||||
self.transfer_progress["files_to_transfer"] = count
|
||||
|
||||
# Utilities expected by tests/UI
|
||||
def get_progress_percentage(self) -> float:
|
||||
@@ -153,38 +153,48 @@ class ProgressTracker:
|
||||
|
||||
class TransferProgressEstimator:
|
||||
"""Estimates transfer progress based on various factors."""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def estimate_progress_from_time(start_time: float, last_progress: int,
|
||||
elapsed_threshold: float = 1.0,
|
||||
max_increment: int = 20,
|
||||
max_progress: int = 90) -> Optional[int]:
|
||||
def estimate_progress_from_time(
|
||||
start_time: float,
|
||||
last_progress: int,
|
||||
elapsed_threshold: float = 1.0,
|
||||
max_increment: int = 20,
|
||||
max_progress: int = 90,
|
||||
) -> Optional[int]:
|
||||
"""Estimate progress based on elapsed time."""
|
||||
current_time = time.time()
|
||||
elapsed_time = current_time - start_time
|
||||
|
||||
|
||||
if elapsed_time >= elapsed_threshold and last_progress < max_progress:
|
||||
new_progress = min(last_progress + max_increment, max_progress)
|
||||
return int(new_progress)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def estimate_progress_from_activity(line_count: int, last_progress: int,
|
||||
activity_threshold: int = 50,
|
||||
increment: int = 5,
|
||||
max_progress: int = 90) -> Optional[int]:
|
||||
def estimate_progress_from_activity(
|
||||
line_count: int,
|
||||
last_progress: int,
|
||||
activity_threshold: int = 50,
|
||||
increment: int = 5,
|
||||
max_progress: int = 90,
|
||||
) -> Optional[int]:
|
||||
"""Estimate progress based on output line activity."""
|
||||
if line_count % activity_threshold == 0 and last_progress < max_progress:
|
||||
calculated_increment = max(1, min(increment, max_progress // (line_count // activity_threshold + 1)))
|
||||
calculated_increment = max(
|
||||
1,
|
||||
min(increment, max_progress // (line_count // activity_threshold + 1)),
|
||||
)
|
||||
new_progress = min(last_progress + calculated_increment, max_progress)
|
||||
return int(new_progress)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def estimate_complex_progress(line_count: int, elapsed_time: float,
|
||||
last_progress: int) -> Optional[int]:
|
||||
def estimate_complex_progress(
|
||||
line_count: int, elapsed_time: float, last_progress: int
|
||||
) -> Optional[int]:
|
||||
"""Estimate progress using complex algorithm for large transfers."""
|
||||
if elapsed_time >= 2.0 and last_progress < 95:
|
||||
if line_count > 100:
|
||||
@@ -193,7 +203,7 @@ class TransferProgressEstimator:
|
||||
new_progress = min(activity_factor + time_factor, 95)
|
||||
else:
|
||||
new_progress = min(last_progress + 10, 95)
|
||||
|
||||
|
||||
return int(new_progress)
|
||||
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
GUI Components Package
|
||||
Reusable UI components and widgets.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -16,10 +16,9 @@ class AndroidFileBrowser:
|
||||
self.adb_manager = adb_manager
|
||||
self.path_callback = path_callback
|
||||
|
||||
|
||||
def show_browser(self, direction="pull"):
|
||||
"""Show a browsable Android folder tree.
|
||||
|
||||
"""Show a browsable Android folder tree.
|
||||
|
||||
Args:
|
||||
direction: "pull" to show files and folders, "push" to show folders only
|
||||
"""
|
||||
@@ -40,7 +39,7 @@ class AndroidFileBrowser:
|
||||
else:
|
||||
browser_window.title("Browse Android Files and Folders")
|
||||
label_text = "Browse Android device files and folders:"
|
||||
|
||||
|
||||
browser_window.geometry("500x400")
|
||||
browser_window.transient(self.parent)
|
||||
browser_window.grab_set()
|
||||
@@ -212,10 +211,7 @@ class AndroidFileBrowser:
|
||||
# Additional validation and cleanup
|
||||
file_name = file_name.strip()
|
||||
|
||||
if (
|
||||
file_name
|
||||
and not file_name.startswith(".")
|
||||
):
|
||||
if file_name and not file_name.startswith("."):
|
||||
files.append(file_name)
|
||||
|
||||
# Add folders to tree first (sorted)
|
||||
@@ -230,7 +226,7 @@ class AndroidFileBrowser:
|
||||
)
|
||||
# Add a dummy child to make it expandable
|
||||
tree.insert(item, "end", text="Loading...")
|
||||
|
||||
|
||||
# Add files to tree (sorted) - only if not in push mode
|
||||
if files and direction != "push":
|
||||
for file in sorted(files):
|
||||
@@ -241,10 +237,14 @@ class AndroidFileBrowser:
|
||||
text=f"📄 {file}",
|
||||
values=[file_path, "file"],
|
||||
)
|
||||
|
||||
|
||||
# If no folders or files found, show indicator
|
||||
if not folders and (not files or direction == "push"):
|
||||
empty_text = "(No Folders)" if direction == "push" else "(Empty Directory)"
|
||||
empty_text = (
|
||||
"(No Folders)"
|
||||
if direction == "push"
|
||||
else "(Empty Directory)"
|
||||
)
|
||||
tree.insert(
|
||||
parent_item, "end", text=empty_text, values=["", ""]
|
||||
)
|
||||
@@ -455,7 +455,7 @@ class AndroidFileBrowser:
|
||||
def select_current_item():
|
||||
"""Intelligently select the currently highlighted item (file or folder)."""
|
||||
selected_item = tree.selection()[0] if tree.selection() else None
|
||||
|
||||
|
||||
if selected_item:
|
||||
# Get the item type and path from values
|
||||
item_values = tree.item(selected_item, "values")
|
||||
@@ -465,7 +465,10 @@ class AndroidFileBrowser:
|
||||
if len(item_values) >= 2 and item_values[1] == "file":
|
||||
if direction == "push":
|
||||
# In push mode, don't allow file selection
|
||||
messagebox.showwarning("Invalid Selection", "Please select a folder as the destination.")
|
||||
messagebox.showwarning(
|
||||
"Invalid Selection",
|
||||
"Please select a folder as the destination.",
|
||||
)
|
||||
return
|
||||
else:
|
||||
# It's a file and we're in pull mode - select the file path directly
|
||||
@@ -479,7 +482,7 @@ class AndroidFileBrowser:
|
||||
self.path_callback(item_path)
|
||||
browser_window.destroy()
|
||||
return
|
||||
|
||||
|
||||
# Fallback: use current path (for backwards compatibility or when no specific item is selected)
|
||||
current_path = current_path_var.get()
|
||||
if current_path and current_path.strip():
|
||||
@@ -488,11 +491,13 @@ class AndroidFileBrowser:
|
||||
browser_window.destroy()
|
||||
else:
|
||||
selection_type = "folder" if direction == "push" else "file or folder"
|
||||
messagebox.showwarning("No Selection", f"Please select a {selection_type}.")
|
||||
messagebox.showwarning(
|
||||
"No Selection", f"Please select a {selection_type}."
|
||||
)
|
||||
|
||||
tk.Button(
|
||||
button_frame, text="Select", command=select_current_item
|
||||
).pack(side="left", padx=5)
|
||||
tk.Button(button_frame, text="Select", command=select_current_item).pack(
|
||||
side="left", padx=5
|
||||
)
|
||||
tk.Button(button_frame, text="Cancel", command=browser_window.destroy).pack(
|
||||
side="left", padx=5
|
||||
)
|
||||
|
||||
@@ -8,17 +8,23 @@ from tkinter import messagebox
|
||||
from typing import Callable, Optional
|
||||
|
||||
try:
|
||||
from ..dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement
|
||||
from ..dialogs.license_agreement import (
|
||||
LicenseAgreementFrame,
|
||||
check_license_agreement,
|
||||
)
|
||||
except ImportError:
|
||||
from gui.dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement
|
||||
from gui.dialogs.license_agreement import (
|
||||
LicenseAgreementFrame,
|
||||
check_license_agreement,
|
||||
)
|
||||
|
||||
|
||||
class PathSelectorFrame:
|
||||
"""Frame component for path selection with browse button."""
|
||||
|
||||
|
||||
def __init__(self, parent: tk.Widget, label_text: str, browse_command: Callable):
|
||||
"""Initialize the path selector frame.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
label_text: Text for the label
|
||||
@@ -27,67 +33,61 @@ class PathSelectorFrame:
|
||||
self.frame = tk.Frame(parent)
|
||||
self.label = tk.Label(self.frame, text=label_text)
|
||||
self.label.pack(anchor="w")
|
||||
|
||||
|
||||
# Path frame with label and button
|
||||
path_frame = tk.Frame(self.frame)
|
||||
path_frame.pack(fill="x", pady=(0, 10))
|
||||
|
||||
|
||||
self.path_var = tk.StringVar(value="Please select file or folder ->")
|
||||
self.path_display = tk.Label(
|
||||
path_frame,
|
||||
textvariable=self.path_var,
|
||||
anchor="w"
|
||||
)
|
||||
self.path_display = tk.Label(path_frame, textvariable=self.path_var, anchor="w")
|
||||
self.path_display.pack(side="left", fill="x", expand=True)
|
||||
|
||||
|
||||
self.browse_btn = tk.Button(
|
||||
path_frame,
|
||||
text="Browse...",
|
||||
command=browse_command
|
||||
path_frame, text="Browse...", command=browse_command
|
||||
)
|
||||
self.browse_btn.pack(side="right", padx=(5, 0))
|
||||
|
||||
|
||||
def pack(self, **kwargs):
|
||||
"""Pack the frame."""
|
||||
self.frame.pack(**kwargs)
|
||||
|
||||
|
||||
def pack_forget(self):
|
||||
"""Remove the frame from packing."""
|
||||
self.frame.pack_forget()
|
||||
|
||||
|
||||
def set_path(self, path: str) -> None:
|
||||
"""Set the displayed path.
|
||||
|
||||
|
||||
Args:
|
||||
path: Path to display
|
||||
"""
|
||||
self.path_var.set(path)
|
||||
|
||||
|
||||
def get_path(self) -> str:
|
||||
"""Get the current path.
|
||||
|
||||
|
||||
Returns:
|
||||
Current path string
|
||||
"""
|
||||
return self.path_var.get().strip()
|
||||
|
||||
|
||||
def is_path_selected(self) -> bool:
|
||||
"""Check if a valid path is selected.
|
||||
|
||||
|
||||
Returns:
|
||||
True if path is selected and not the default placeholder
|
||||
"""
|
||||
path = self.get_path()
|
||||
return path and path != "Please select file or folder ->"
|
||||
|
||||
|
||||
def clear_path(self) -> None:
|
||||
"""Clear the path selection."""
|
||||
self.path_var.set("Please select file or folder ->")
|
||||
|
||||
|
||||
def enable_browse(self) -> None:
|
||||
"""Enable the browse button."""
|
||||
self.browse_btn.config(state="normal")
|
||||
|
||||
|
||||
def disable_browse(self) -> None:
|
||||
"""Disable the browse button."""
|
||||
self.browse_btn.config(state="disabled")
|
||||
@@ -95,19 +95,19 @@ class PathSelectorFrame:
|
||||
|
||||
class DirectionSelector:
|
||||
"""Component for selecting transfer direction (pull/push)."""
|
||||
|
||||
|
||||
def __init__(self, parent: tk.Widget, on_change_command: Optional[Callable] = None):
|
||||
"""Initialize the direction selector.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_change_command: Command to execute when direction changes
|
||||
"""
|
||||
self.direction_var = tk.StringVar(value="pull")
|
||||
|
||||
|
||||
self.frame = tk.Frame(parent)
|
||||
self.frame.pack(anchor="w", padx=10, pady=(10, 0))
|
||||
|
||||
|
||||
tk.Radiobutton(
|
||||
self.frame,
|
||||
text="Pull (Android → Computer)",
|
||||
@@ -115,7 +115,7 @@ class DirectionSelector:
|
||||
value="pull",
|
||||
command=on_change_command,
|
||||
).pack(side="left")
|
||||
|
||||
|
||||
tk.Radiobutton(
|
||||
self.frame,
|
||||
text="Push (Computer → Android)",
|
||||
@@ -123,10 +123,10 @@ class DirectionSelector:
|
||||
value="push",
|
||||
command=on_change_command,
|
||||
).pack(side="left", padx=(20, 0))
|
||||
|
||||
|
||||
def get_direction(self) -> str:
|
||||
"""Get the current direction.
|
||||
|
||||
|
||||
Returns:
|
||||
Current direction ('pull' or 'push')
|
||||
"""
|
||||
@@ -135,10 +135,10 @@ class DirectionSelector:
|
||||
|
||||
class StatusLabel:
|
||||
"""Responsive status label with word wrapping."""
|
||||
|
||||
|
||||
def __init__(self, parent: tk.Widget, initial_text: str = "Status: Idle"):
|
||||
"""Initialize the status label.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
initial_text: Initial status text
|
||||
@@ -148,37 +148,40 @@ class StatusLabel:
|
||||
text=initial_text,
|
||||
wraplength=0, # Will be set dynamically
|
||||
justify="center",
|
||||
anchor="center"
|
||||
anchor="center",
|
||||
)
|
||||
self.label.pack(padx=10, fill="x", pady=(20, 5))
|
||||
|
||||
|
||||
# Bind parent window resize to update wrapping
|
||||
parent.bind("<Configure>", self._on_window_configure)
|
||||
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""Set the status text.
|
||||
|
||||
|
||||
Args:
|
||||
text: Text to display
|
||||
"""
|
||||
self.label.config(text=text)
|
||||
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""Get the current status text.
|
||||
|
||||
|
||||
Returns:
|
||||
Current status text
|
||||
"""
|
||||
return self.label.cget("text")
|
||||
|
||||
|
||||
def _on_window_configure(self, event) -> None:
|
||||
"""Handle window resize events to update label wrapping.
|
||||
|
||||
|
||||
Args:
|
||||
event: Configure event
|
||||
"""
|
||||
# Only handle configure events for the main window, not child widgets
|
||||
if hasattr(event.widget, 'winfo_toplevel') and event.widget == event.widget.winfo_toplevel():
|
||||
if (
|
||||
hasattr(event.widget, "winfo_toplevel")
|
||||
and event.widget == event.widget.winfo_toplevel()
|
||||
):
|
||||
# Calculate available width for the status label
|
||||
# Account for padding (10px on each side) and some margin
|
||||
available_width = event.widget.winfo_width() - 40
|
||||
@@ -188,25 +191,21 @@ class StatusLabel:
|
||||
|
||||
class TransferButton:
|
||||
"""Multi-mode transfer button that changes text and behavior based on state."""
|
||||
|
||||
|
||||
def __init__(self, parent: tk.Widget):
|
||||
"""Initialize the transfer button.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
self.button = tk.Button(
|
||||
parent,
|
||||
text="Start Transfer",
|
||||
state="disabled"
|
||||
)
|
||||
self.button = tk.Button(parent, text="Start Transfer", state="disabled")
|
||||
self.button.pack(pady=10)
|
||||
|
||||
|
||||
self.current_mode = "transfer" # transfer, recheck, cancel
|
||||
|
||||
|
||||
def set_transfer_mode(self, command: Callable, enabled: bool = True) -> None:
|
||||
"""Set button to transfer mode.
|
||||
|
||||
|
||||
Args:
|
||||
command: Command to execute on button click
|
||||
enabled: Whether button should be enabled
|
||||
@@ -215,53 +214,44 @@ class TransferButton:
|
||||
self.button.config(
|
||||
text="Start Transfer",
|
||||
command=command,
|
||||
state="normal" if enabled else "disabled"
|
||||
state="normal" if enabled else "disabled",
|
||||
)
|
||||
|
||||
|
||||
def set_recheck_mode(self, command: Callable) -> None:
|
||||
"""Set button to recheck device mode.
|
||||
|
||||
|
||||
Args:
|
||||
command: Command to execute on button click
|
||||
"""
|
||||
self.current_mode = "recheck"
|
||||
self.button.config(
|
||||
text="Recheck for connected Android device",
|
||||
command=command,
|
||||
state="normal"
|
||||
text="Recheck for connected Android device", command=command, state="normal"
|
||||
)
|
||||
|
||||
|
||||
def set_cancel_mode(self, command: Callable) -> None:
|
||||
"""Set button to cancel transfer mode.
|
||||
|
||||
|
||||
Args:
|
||||
command: Command to execute on button click
|
||||
"""
|
||||
self.current_mode = "cancel"
|
||||
self.button.config(
|
||||
text="Cancel Transfer",
|
||||
command=command,
|
||||
state="normal"
|
||||
)
|
||||
|
||||
self.button.config(text="Cancel Transfer", command=command, state="normal")
|
||||
|
||||
def set_checking_mode(self) -> None:
|
||||
"""Set button to temporary checking state."""
|
||||
self.button.config(
|
||||
text="Checking...",
|
||||
state="disabled"
|
||||
)
|
||||
|
||||
self.button.config(text="Checking...", state="disabled")
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable the button."""
|
||||
self.button.config(state="normal")
|
||||
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable the button."""
|
||||
self.button.config(state="disabled")
|
||||
|
||||
|
||||
def get_mode(self) -> str:
|
||||
"""Get the current button mode.
|
||||
|
||||
|
||||
Returns:
|
||||
Current mode string ('transfer', 'recheck', 'cancel')
|
||||
"""
|
||||
@@ -270,10 +260,10 @@ class TransferButton:
|
||||
|
||||
class LicenseManager:
|
||||
"""Manages license agreement display and main interface switching."""
|
||||
|
||||
|
||||
def __init__(self, parent_window: tk.Tk):
|
||||
"""Initialize the license manager.
|
||||
|
||||
|
||||
Args:
|
||||
parent_window: The main window instance
|
||||
"""
|
||||
@@ -281,39 +271,39 @@ class LicenseManager:
|
||||
self.license_agreed = check_license_agreement()
|
||||
self.license_frame = None
|
||||
self.on_agreed_callback: Optional[Callable] = None
|
||||
|
||||
|
||||
def needs_license_agreement(self) -> bool:
|
||||
"""Check if license agreement is needed.
|
||||
|
||||
|
||||
Returns:
|
||||
True if license agreement needs to be shown, False otherwise
|
||||
"""
|
||||
return not self.license_agreed
|
||||
|
||||
|
||||
def show_license_agreement(self, on_agreed_callback: Callable) -> None:
|
||||
"""Show the license agreement interface.
|
||||
|
||||
|
||||
Args:
|
||||
on_agreed_callback: Callback to execute when license is agreed
|
||||
"""
|
||||
self.on_agreed_callback = on_agreed_callback
|
||||
|
||||
|
||||
# Adjust window size for license agreement
|
||||
self.parent.geometry("700x600")
|
||||
self.parent.minsize(700, 600)
|
||||
|
||||
|
||||
# Create license agreement frame
|
||||
self.license_frame = LicenseAgreementFrame(self.parent, self._on_license_agreed)
|
||||
|
||||
|
||||
def _on_license_agreed(self) -> None:
|
||||
"""Handle when user agrees to license."""
|
||||
self.license_agreed = True
|
||||
|
||||
|
||||
# Remove license frame
|
||||
if self.license_frame:
|
||||
self.license_frame.destroy()
|
||||
self.license_frame = None
|
||||
|
||||
|
||||
# Execute callback
|
||||
if self.on_agreed_callback:
|
||||
self.on_agreed_callback()
|
||||
self.on_agreed_callback()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
GUI Dialogs Package
|
||||
Dialog windows and user interaction components.
|
||||
"""
|
||||
"""
|
||||
|
||||
+149
-132
@@ -11,10 +11,10 @@ from typing import Optional, Tuple
|
||||
|
||||
class DialogManager:
|
||||
"""Manages various dialog boxes and user interactions."""
|
||||
|
||||
|
||||
def __init__(self, parent_window: tk.Tk):
|
||||
"""Initialize the dialog manager.
|
||||
|
||||
|
||||
Args:
|
||||
parent_window: The main window instance
|
||||
"""
|
||||
@@ -22,7 +22,6 @@ class DialogManager:
|
||||
self.troubleshooting_steps = (
|
||||
"Android device appears to have been disconnected and/or USB debugging is disabled.\n"
|
||||
"Please ensure your Android device is securely connected at both ends.\n\n"
|
||||
|
||||
"To enable USB debugging:\n"
|
||||
"1. Connect your device to the computer via USB\n"
|
||||
"2. Open Settings → About phone\n"
|
||||
@@ -33,33 +32,28 @@ class DialogManager:
|
||||
" (Tip: Use the search icon at the top if you can't find it)\n"
|
||||
"6. Connect via USB and tap 'Trust' when prompted\n"
|
||||
" (Checking 'Remember' is recommended for future transfers)\n\n"
|
||||
|
||||
"Ensure File Transfer mode is enabled:\n"
|
||||
"1. After connecting, swipe down to view notifications\n"
|
||||
"2. Look for a USB notification (often shows 'Charging over USB')\n"
|
||||
"3. Tap the notification and select 'File Transfer' or 'MTP' mode\n\n"
|
||||
|
||||
"Note: Menu names may vary by Android version:\n"
|
||||
"• Some devices show 'Developer options' under 'System'\n"
|
||||
"• Others may have it directly in the main Settings menu\n"
|
||||
"• Samsung devices might show 'Software information' instead of 'About phone'\n\n"
|
||||
|
||||
"If you're still having trouble:\n"
|
||||
"• Try a different USB cable or port (some cables only support charging)\n"
|
||||
"• Restart both your phone and computer\n"
|
||||
"• Make sure your phone screen is unlocked when connecting\n"
|
||||
"• Set your phone screen timeout to 30 minutes (especially for long transfers)\n\n"
|
||||
"• Use a different computer to test if the issue is computer-specific\n"
|
||||
|
||||
"Windows users: If you see a driver installation popup, please allow it to complete.\n"
|
||||
"Linux users: You may need to run 'sudo usermod -a -G plugdev $USER' and reboot.\n"
|
||||
|
||||
"After completing these steps, click 'Recheck for connected Android device' to try again."
|
||||
)
|
||||
|
||||
|
||||
def show_file_folder_selection_notice(self) -> bool:
|
||||
"""Show instructions for file and folder selection in a custom dialog.
|
||||
|
||||
|
||||
Returns:
|
||||
True if user clicked OK, False if user cancelled or closed dialog
|
||||
"""
|
||||
@@ -71,20 +65,20 @@ class DialogManager:
|
||||
dialog.resizable(True, True)
|
||||
dialog.transient(self.parent)
|
||||
dialog.grab_set()
|
||||
|
||||
|
||||
# Center the dialog on the parent window
|
||||
dialog.update_idletasks()
|
||||
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (600 // 2)
|
||||
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (250 // 2)
|
||||
dialog.geometry(f"600x250+{x}+{y}")
|
||||
|
||||
|
||||
# Track if OK was clicked
|
||||
dialog_confirmed = False
|
||||
|
||||
|
||||
# Create main frame
|
||||
main_frame = tk.Frame(dialog)
|
||||
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
|
||||
# Selection notice text
|
||||
notice_text = (
|
||||
"How file and folder selection works in this application\n\n"
|
||||
@@ -93,7 +87,7 @@ class DialogManager:
|
||||
"(the current directory you are viewing, not a highlighted folder) will be selected for transfer.\n\n"
|
||||
"You can only transfer one file or one folder at a time."
|
||||
)
|
||||
|
||||
|
||||
# Create responsive text label
|
||||
text_label = tk.Label(
|
||||
main_frame,
|
||||
@@ -101,54 +95,56 @@ class DialogManager:
|
||||
justify="left",
|
||||
anchor="nw",
|
||||
wraplength=0, # Will be set dynamically
|
||||
font=("Arial", 10)
|
||||
font=("Arial", 10),
|
||||
)
|
||||
text_label.pack(fill="both", expand=True, pady=(0, 20))
|
||||
|
||||
|
||||
# OK button with confirmation callback
|
||||
def on_ok_clicked():
|
||||
nonlocal dialog_confirmed
|
||||
dialog_confirmed = True
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
ok_button = tk.Button(
|
||||
main_frame,
|
||||
text="OK",
|
||||
command=on_ok_clicked,
|
||||
width=10,
|
||||
font=("Arial", 10)
|
||||
main_frame, text="OK", command=on_ok_clicked, width=10, font=("Arial", 10)
|
||||
)
|
||||
ok_button.pack(pady=10)
|
||||
|
||||
|
||||
# Configure text wrapping on dialog resize
|
||||
def on_dialog_configure(event):
|
||||
if event.widget == dialog:
|
||||
# Calculate available width for text (account for padding and margins)
|
||||
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
|
||||
available_width = (
|
||||
dialog.winfo_width() - 60
|
||||
) # 20px padding * 2 + some margin
|
||||
if available_width > 200: # Minimum reasonable width
|
||||
text_label.config(wraplength=available_width)
|
||||
|
||||
|
||||
dialog.bind("<Configure>", on_dialog_configure)
|
||||
|
||||
|
||||
# Set initial wrap length
|
||||
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
|
||||
|
||||
dialog.after(
|
||||
10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})())
|
||||
)
|
||||
|
||||
# Handle window close (X button) - treat as cancel
|
||||
def on_dialog_close():
|
||||
nonlocal dialog_confirmed
|
||||
dialog_confirmed = False
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
dialog.protocol("WM_DELETE_WINDOW", on_dialog_close)
|
||||
|
||||
|
||||
# Wait for dialog to close
|
||||
dialog.wait_window()
|
||||
|
||||
|
||||
return dialog_confirmed
|
||||
|
||||
def show_enable_debugging_instructions(self, callback: Optional[callable] = None) -> None:
|
||||
|
||||
def show_enable_debugging_instructions(
|
||||
self, callback: Optional[callable] = None
|
||||
) -> None:
|
||||
"""Show instructions to connect device, enable file transfer, and enable USB debugging.
|
||||
|
||||
|
||||
Args:
|
||||
callback: Optional callback to execute after dialog is closed
|
||||
"""
|
||||
@@ -160,17 +156,17 @@ class DialogManager:
|
||||
dialog.resizable(True, True)
|
||||
dialog.transient(self.parent)
|
||||
dialog.grab_set()
|
||||
|
||||
|
||||
# Center the dialog on the parent window
|
||||
dialog.update_idletasks()
|
||||
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (700 // 2)
|
||||
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (450 // 2)
|
||||
dialog.geometry(f"700x450+{x}+{y}")
|
||||
|
||||
|
||||
# Create main frame
|
||||
main_frame = tk.Frame(dialog)
|
||||
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
|
||||
# Create responsive text label
|
||||
text_label = tk.Label(
|
||||
main_frame,
|
||||
@@ -178,43 +174,43 @@ class DialogManager:
|
||||
justify="left",
|
||||
anchor="nw",
|
||||
wraplength=0, # Will be set dynamically
|
||||
font=("Arial", 10)
|
||||
font=("Arial", 10),
|
||||
)
|
||||
text_label.pack(fill="both", expand=True, pady=(0, 20))
|
||||
|
||||
|
||||
# OK button
|
||||
ok_button = tk.Button(
|
||||
main_frame,
|
||||
text="OK",
|
||||
command=dialog.destroy,
|
||||
width=10,
|
||||
font=("Arial", 10)
|
||||
main_frame, text="OK", command=dialog.destroy, width=10, font=("Arial", 10)
|
||||
)
|
||||
ok_button.pack(pady=10)
|
||||
|
||||
|
||||
# Configure text wrapping on dialog resize
|
||||
def on_dialog_configure(event):
|
||||
if event.widget == dialog:
|
||||
# Calculate available width for text (account for padding and margins)
|
||||
available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin
|
||||
available_width = (
|
||||
dialog.winfo_width() - 60
|
||||
) # 20px padding * 2 + some margin
|
||||
if available_width > 200: # Minimum reasonable width
|
||||
text_label.config(wraplength=available_width)
|
||||
|
||||
|
||||
dialog.bind("<Configure>", on_dialog_configure)
|
||||
|
||||
|
||||
# Set initial wrap length
|
||||
dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})()))
|
||||
|
||||
dialog.after(
|
||||
10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})())
|
||||
)
|
||||
|
||||
# Handle dialog close
|
||||
def on_close():
|
||||
dialog.destroy()
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
|
||||
# After user clicks OK, ensure callback is executed
|
||||
dialog.protocol("WM_DELETE_WINDOW", on_close)
|
||||
ok_button.config(command=on_close)
|
||||
|
||||
|
||||
def show_disable_debugging_reminder(self) -> None:
|
||||
"""Show reminder to disable USB debugging after transfer."""
|
||||
msg = (
|
||||
@@ -223,10 +219,12 @@ class DialogManager:
|
||||
"Settings → Developer Options → disable 'USB debugging'."
|
||||
)
|
||||
messagebox.showinfo("Disable USB Debugging", msg)
|
||||
|
||||
def show_transfer_stats(self, stats: dict, operation: str, deduplicator=None) -> None:
|
||||
|
||||
def show_transfer_stats(
|
||||
self, stats: dict, operation: str, deduplicator=None
|
||||
) -> None:
|
||||
"""Show transfer statistics dialog.
|
||||
|
||||
|
||||
Args:
|
||||
stats: Dictionary containing transfer statistics
|
||||
operation: Type of operation ("Pull" or "Push")
|
||||
@@ -234,98 +232,104 @@ class DialogManager:
|
||||
"""
|
||||
# Format bytes saved
|
||||
bytes_saved_str = ""
|
||||
if stats['bytes_saved'] > 0:
|
||||
if deduplicator and hasattr(deduplicator, 'format_bytes'):
|
||||
bytes_saved_str = f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)"
|
||||
if stats["bytes_saved"] > 0:
|
||||
if deduplicator and hasattr(deduplicator, "format_bytes"):
|
||||
bytes_saved_str = (
|
||||
f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)"
|
||||
)
|
||||
else:
|
||||
bytes_saved_str = f" ({stats['bytes_saved']} bytes saved)"
|
||||
|
||||
|
||||
# Build message
|
||||
title = f"{operation} Transfer Complete"
|
||||
|
||||
if stats['total_files'] == 0:
|
||||
|
||||
if stats["total_files"] == 0:
|
||||
message = "No files were found to transfer."
|
||||
else:
|
||||
message_parts = [
|
||||
f"Transfer completed successfully!\n",
|
||||
f"Files found: {stats['total_files']}",
|
||||
f"Files transferred: {stats['transferred']}",
|
||||
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}"
|
||||
f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}",
|
||||
]
|
||||
|
||||
if stats['skipped'] > 0:
|
||||
message_parts.append(f"\nDuplicate detection helped avoid unnecessary transfers!")
|
||||
|
||||
|
||||
if stats["skipped"] > 0:
|
||||
message_parts.append(
|
||||
f"\nDuplicate detection helped avoid unnecessary transfers!"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
|
||||
# Show dialog
|
||||
messagebox.showinfo(title, message)
|
||||
|
||||
|
||||
def show_error(self, title: str, message: str) -> None:
|
||||
"""Show an error dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Error message to display
|
||||
"""
|
||||
messagebox.showerror(title, message)
|
||||
|
||||
|
||||
def show_info(self, title: str, message: str) -> None:
|
||||
"""Show an info dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Info message to display
|
||||
"""
|
||||
messagebox.showinfo(title, message)
|
||||
|
||||
def browse_local_file_or_folder(self, direction: str, initial_dir: Optional[str] = None) -> Optional[str]:
|
||||
|
||||
def browse_local_file_or_folder(
|
||||
self, direction: str, initial_dir: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Browse for local file or folder based on transfer direction.
|
||||
|
||||
|
||||
Args:
|
||||
direction: Transfer direction ('pull' or 'push')
|
||||
initial_dir: Initial directory to open browser in
|
||||
|
||||
|
||||
Returns:
|
||||
Selected path or None if cancelled
|
||||
"""
|
||||
if initial_dir is None:
|
||||
initial_dir = os.path.expanduser("~")
|
||||
|
||||
|
||||
# Show helpful notification about folder selection behavior
|
||||
if not self.show_file_folder_selection_notice():
|
||||
return None # User cancelled the notice dialog
|
||||
|
||||
|
||||
if direction == "push":
|
||||
# For push, show file selection first, then folder selection if cancelled
|
||||
|
||||
|
||||
# First try file selection
|
||||
selected_path = filedialog.askopenfilename(
|
||||
title="Select file to push to Android device",
|
||||
initialdir=initial_dir,
|
||||
filetypes=[("All files", "*.*")]
|
||||
filetypes=[("All files", "*.*")],
|
||||
)
|
||||
|
||||
|
||||
# If no file was selected, offer folder selection as an alternative
|
||||
if not selected_path:
|
||||
selected_path = filedialog.askdirectory(
|
||||
title="Select folder to push to Android device",
|
||||
initialdir=initial_dir
|
||||
initialdir=initial_dir,
|
||||
)
|
||||
|
||||
|
||||
return selected_path if selected_path else None
|
||||
|
||||
|
||||
else: # pull direction
|
||||
# For pull, only allow folder selection (destination)
|
||||
folder = filedialog.askdirectory(
|
||||
title="Select destination folder for pulled files",
|
||||
initialdir=initial_dir
|
||||
initialdir=initial_dir,
|
||||
)
|
||||
return folder if folder else None
|
||||
|
||||
|
||||
def show_file_folder_choice(self, on_file_callback, on_folder_callback):
|
||||
"""Show a dialog to choose between file or folder selection.
|
||||
|
||||
|
||||
Args:
|
||||
on_file_callback: Callback for file selection
|
||||
on_folder_callback: Callback for folder selection
|
||||
@@ -337,94 +341,107 @@ class DialogManager:
|
||||
choice_window.resizable(False, False)
|
||||
choice_window.transient(self.parent)
|
||||
choice_window.grab_set()
|
||||
|
||||
|
||||
# Center the window
|
||||
choice_window.geometry("+{}+{}".format(
|
||||
self.parent.winfo_x() + 100,
|
||||
self.parent.winfo_y() + 100
|
||||
))
|
||||
|
||||
choice_window.geometry(
|
||||
"+{}+{}".format(self.parent.winfo_x() + 100, self.parent.winfo_y() + 100)
|
||||
)
|
||||
|
||||
main_frame = tk.Frame(choice_window)
|
||||
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
tk.Label(main_frame, text="What would you like to select?",
|
||||
font=("Arial", 11)).pack(pady=(0, 15))
|
||||
|
||||
|
||||
tk.Label(
|
||||
main_frame, text="What would you like to select?", font=("Arial", 11)
|
||||
).pack(pady=(0, 15))
|
||||
|
||||
button_frame = tk.Frame(main_frame)
|
||||
button_frame.pack(fill="x")
|
||||
|
||||
|
||||
def select_file():
|
||||
choice_window.destroy()
|
||||
on_file_callback()
|
||||
|
||||
|
||||
def select_folder():
|
||||
choice_window.destroy()
|
||||
on_folder_callback()
|
||||
|
||||
|
||||
def cancel_selection():
|
||||
choice_window.destroy()
|
||||
|
||||
tk.Button(button_frame, text="📄 File", command=select_file,
|
||||
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
|
||||
tk.Button(button_frame, text="📁 Folder", command=select_folder,
|
||||
width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10))
|
||||
tk.Button(button_frame, text="Cancel", command=cancel_selection,
|
||||
width=12).pack(side="right")
|
||||
|
||||
|
||||
tk.Button(
|
||||
button_frame,
|
||||
text="📄 File",
|
||||
command=select_file,
|
||||
width=12,
|
||||
font=("Arial", 10),
|
||||
).pack(side="left", padx=(0, 10))
|
||||
tk.Button(
|
||||
button_frame,
|
||||
text="📁 Folder",
|
||||
command=select_folder,
|
||||
width=12,
|
||||
font=("Arial", 10),
|
||||
).pack(side="left", padx=(0, 10))
|
||||
tk.Button(button_frame, text="Cancel", command=cancel_selection, width=12).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
def show_transfer_stats(self, stats: dict, operation: str):
|
||||
"""Show transfer statistics in a dialog.
|
||||
|
||||
|
||||
Args:
|
||||
stats: Dictionary containing transfer statistics
|
||||
operation: Description of the operation performed
|
||||
"""
|
||||
if not stats:
|
||||
return
|
||||
|
||||
|
||||
stats_window = tk.Toplevel(self.parent)
|
||||
stats_window.title("Transfer Statistics")
|
||||
stats_window.geometry("400x300")
|
||||
stats_window.resizable(True, True)
|
||||
stats_window.transient(self.parent)
|
||||
stats_window.grab_set()
|
||||
|
||||
|
||||
# Center the window
|
||||
stats_window.geometry("+{}+{}".format(
|
||||
self.parent.winfo_x() + 60,
|
||||
self.parent.winfo_y() + 60
|
||||
))
|
||||
|
||||
stats_window.geometry(
|
||||
"+{}+{}".format(self.parent.winfo_x() + 60, self.parent.winfo_y() + 60)
|
||||
)
|
||||
|
||||
main_frame = tk.Frame(stats_window)
|
||||
main_frame.pack(fill="both", expand=True, padx=15, pady=15)
|
||||
|
||||
|
||||
# Title
|
||||
title_label = tk.Label(main_frame, text=f"📊 Transfer Complete - {operation.title()}",
|
||||
font=("Arial", 14, "bold"))
|
||||
title_label = tk.Label(
|
||||
main_frame,
|
||||
text=f"📊 Transfer Complete - {operation.title()}",
|
||||
font=("Arial", 14, "bold"),
|
||||
)
|
||||
title_label.pack(pady=(0, 15))
|
||||
|
||||
|
||||
# Stats display
|
||||
stats_text = tk.Text(main_frame, wrap=tk.WORD, font=("Courier", 10),
|
||||
height=12, width=50)
|
||||
stats_text = tk.Text(
|
||||
main_frame, wrap=tk.WORD, font=("Courier", 10), height=12, width=50
|
||||
)
|
||||
stats_text.pack(fill="both", expand=True, pady=(0, 15))
|
||||
|
||||
|
||||
# Format and insert stats
|
||||
stats_content = []
|
||||
for key, value in stats.items():
|
||||
formatted_key = key.replace('_', ' ').title()
|
||||
formatted_key = key.replace("_", " ").title()
|
||||
stats_content.append(f"{formatted_key}: {value}")
|
||||
|
||||
|
||||
stats_text.insert(tk.END, "\n".join(stats_content))
|
||||
stats_text.config(state=tk.DISABLED)
|
||||
|
||||
|
||||
# Close button
|
||||
tk.Button(main_frame, text="Close",
|
||||
command=stats_window.destroy).pack()
|
||||
|
||||
tk.Button(main_frame, text="Close", command=stats_window.destroy).pack()
|
||||
|
||||
def show_disable_debugging_reminder(self):
|
||||
"""Show reminder about disabling USB debugging after transfer."""
|
||||
messagebox.showinfo(
|
||||
"Security Reminder",
|
||||
"Security Reminder",
|
||||
"Transfer complete!\n\nFor security, consider disabling USB debugging when not needed:\n"
|
||||
"Settings → Developer Options → USB debugging (toggle off)\n\n"
|
||||
"Keep it enabled if you plan to transfer files again soon."
|
||||
)
|
||||
"Keep it enabled if you plan to transfer files again soon.",
|
||||
)
|
||||
|
||||
@@ -9,9 +9,6 @@ import tkinter as tk
|
||||
from tkinter import messagebox, scrolledtext
|
||||
import tempfile
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
def get_license_file_path() -> str:
|
||||
@@ -107,42 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE."""
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""Return absolute path to resource for dev and frozen runs."""
|
||||
try:
|
||||
if getattr(sys, "frozen", False):
|
||||
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
|
||||
else:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.normpath(os.path.join(base, relative_path))
|
||||
except Exception:
|
||||
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path))
|
||||
|
||||
|
||||
def run_windows_first_run_if_needed() -> None:
|
||||
"""If running on Windows and license not agreed, launch first-run installer script.
|
||||
|
||||
Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes.
|
||||
"""
|
||||
try:
|
||||
if not sys.platform.startswith("win"):
|
||||
return
|
||||
|
||||
if check_license_agreement():
|
||||
return
|
||||
|
||||
script_rel = os.path.join("scripts", "windows", "first_run_install.ps1")
|
||||
script_path = resource_path(script_rel)
|
||||
if not os.path.exists(script_path):
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class LicenseAgreementFrame(tk.Frame):
|
||||
"""License agreement UI frame that can be embedded in the main window."""
|
||||
|
||||
@@ -156,106 +117,104 @@ class LicenseAgreementFrame(tk.Frame):
|
||||
super().__init__(parent)
|
||||
self.on_agree_callback = on_agree_callback
|
||||
self.setup_ui()
|
||||
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the license agreement UI."""
|
||||
# Configure the frame to fill the window
|
||||
self.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
|
||||
|
||||
|
||||
# Header
|
||||
header_label = tk.Label(
|
||||
self,
|
||||
text="License Agreement",
|
||||
font=("Arial", 16, "bold")
|
||||
self, text="License Agreement", font=("Arial", 16, "bold")
|
||||
)
|
||||
header_label.pack(pady=(0, 10))
|
||||
|
||||
|
||||
# Instruction
|
||||
instruction_label = tk.Label(
|
||||
self,
|
||||
self,
|
||||
text="Please read and accept the license agreement to continue using Android File Handler:",
|
||||
font=("Arial", 10),
|
||||
wraplength=500
|
||||
wraplength=500,
|
||||
)
|
||||
instruction_label.pack(pady=(0, 15))
|
||||
|
||||
|
||||
# License text area
|
||||
text_frame = tk.Frame(self)
|
||||
text_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 20))
|
||||
|
||||
|
||||
self.license_text = scrolledtext.ScrolledText(
|
||||
text_frame,
|
||||
wrap=tk.WORD,
|
||||
width=80,
|
||||
text_frame,
|
||||
wrap=tk.WORD,
|
||||
width=80,
|
||||
height=20,
|
||||
font=("Courier", 9),
|
||||
state=tk.DISABLED,
|
||||
bg="#f8f8f8",
|
||||
relief=tk.SUNKEN,
|
||||
bd=2
|
||||
bd=2,
|
||||
)
|
||||
self.license_text.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
|
||||
# Insert license text
|
||||
self.license_text.config(state=tk.NORMAL)
|
||||
self.license_text.insert(tk.END, get_mit_license_text())
|
||||
self.license_text.config(state=tk.DISABLED)
|
||||
|
||||
|
||||
# Button frame
|
||||
button_frame = tk.Frame(self)
|
||||
button_frame.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
|
||||
# Buttons
|
||||
self.agree_btn = tk.Button(
|
||||
button_frame,
|
||||
text="I Agree",
|
||||
button_frame,
|
||||
text="I Agree",
|
||||
command=self.on_agree,
|
||||
bg="#4CAF50",
|
||||
fg="white",
|
||||
fg="white",
|
||||
font=("Arial", 11, "bold"),
|
||||
width=15,
|
||||
height=2
|
||||
height=2,
|
||||
)
|
||||
self.agree_btn.pack(side=tk.LEFT)
|
||||
|
||||
|
||||
self.disagree_btn = tk.Button(
|
||||
button_frame,
|
||||
text="Disagree & Exit",
|
||||
button_frame,
|
||||
text="Disagree & Exit",
|
||||
command=self.on_disagree,
|
||||
bg="#ff6b6b",
|
||||
fg="white",
|
||||
font=("Arial", 11, "bold"),
|
||||
width=15,
|
||||
height=2
|
||||
height=2,
|
||||
)
|
||||
self.disagree_btn.pack(side=tk.RIGHT)
|
||||
|
||||
|
||||
# Center text
|
||||
center_label = tk.Label(
|
||||
button_frame,
|
||||
text="You must agree to the license terms to use this software",
|
||||
font=("Arial", 9),
|
||||
fg="#666666"
|
||||
fg="#666666",
|
||||
)
|
||||
center_label.pack(expand=True)
|
||||
|
||||
|
||||
def on_agree(self):
|
||||
"""Handle user clicking Agree."""
|
||||
if save_license_agreement():
|
||||
self.on_agree_callback()
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Could not save license agreement. Please check file permissions and try again."
|
||||
"Error",
|
||||
"Could not save license agreement. Please check file permissions and try again.",
|
||||
)
|
||||
|
||||
|
||||
def on_disagree(self):
|
||||
"""Handle user clicking Disagree."""
|
||||
# Ask for confirmation
|
||||
result = messagebox.askyesno(
|
||||
"Exit Application",
|
||||
"Are you sure you want to exit? You must agree to the license terms to use this software.",
|
||||
icon="warning"
|
||||
icon="warning",
|
||||
)
|
||||
if result:
|
||||
sys.exit(0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
GUI Handlers Package
|
||||
Event handlers and animation controllers.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -9,10 +9,10 @@ from typing import Optional, Dict, Any
|
||||
|
||||
class AnimationHandler:
|
||||
"""Handles all GUI animations including transfer and scanning animations."""
|
||||
|
||||
|
||||
def __init__(self, parent_window: tk.Tk):
|
||||
"""Initialize the animation handler.
|
||||
|
||||
|
||||
Args:
|
||||
parent_window: The main window instance
|
||||
"""
|
||||
@@ -20,46 +20,42 @@ class AnimationHandler:
|
||||
self.animation_job: Optional[str] = None
|
||||
self.animation_dots = 0
|
||||
self.scanning_active = False
|
||||
|
||||
|
||||
# File transfer progress tracking
|
||||
self.transfer_file_progress = {
|
||||
'current': 0,
|
||||
'total': 0,
|
||||
'active': False
|
||||
}
|
||||
|
||||
self.transfer_file_progress = {"current": 0, "total": 0, "active": False}
|
||||
|
||||
def start_scanning_animation(self) -> None:
|
||||
"""Start the 'Scanning for duplicates...' animation."""
|
||||
self.animation_dots = 0
|
||||
self.scanning_active = True
|
||||
self.animation_job = self.parent.after(0, self._animate_scanning_text)
|
||||
|
||||
|
||||
def start_transfer_animation(self) -> None:
|
||||
"""Start the 'Transferring...' animation."""
|
||||
self.animation_dots = 0
|
||||
self.scanning_active = False
|
||||
self.animation_job = self.parent.after(0, self._animate_transfer_text)
|
||||
|
||||
|
||||
def stop_animation(self) -> None:
|
||||
"""Stop any running animation."""
|
||||
if self.animation_job is not None:
|
||||
self.parent.after_cancel(self.animation_job)
|
||||
self.animation_job = None
|
||||
|
||||
|
||||
self.scanning_active = False
|
||||
self._reset_file_progress()
|
||||
|
||||
|
||||
def update_transfer_progress(self, current: int, total: int) -> None:
|
||||
"""Update the file transfer progress.
|
||||
|
||||
|
||||
Args:
|
||||
current: Current number of files transferred
|
||||
total: Total number of files to transfer
|
||||
"""
|
||||
self.transfer_file_progress['current'] = current
|
||||
self.transfer_file_progress['total'] = total
|
||||
self.transfer_file_progress['active'] = True
|
||||
|
||||
self.transfer_file_progress["current"] = current
|
||||
self.transfer_file_progress["total"] = total
|
||||
self.transfer_file_progress["active"] = True
|
||||
|
||||
def _animate_scanning_text(self) -> None:
|
||||
"""Animate the scanning text with dots."""
|
||||
if self.animation_job is not None and self.scanning_active:
|
||||
@@ -69,56 +65,54 @@ class AnimationHandler:
|
||||
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
|
||||
# Schedule next update in 500ms
|
||||
self.animation_job = self.parent.after(500, self._animate_scanning_text)
|
||||
|
||||
|
||||
def _animate_transfer_text(self) -> None:
|
||||
"""Animate the transfer text with dots."""
|
||||
if self.animation_job is not None and not self.scanning_active:
|
||||
dots = "." * (self.animation_dots + 1)
|
||||
|
||||
|
||||
# Show file progress if available
|
||||
if (self.transfer_file_progress['active'] and
|
||||
self.transfer_file_progress['total'] > 0):
|
||||
current = self.transfer_file_progress['current']
|
||||
total = self.transfer_file_progress['total']
|
||||
if (
|
||||
self.transfer_file_progress["active"]
|
||||
and self.transfer_file_progress["total"] > 0
|
||||
):
|
||||
current = self.transfer_file_progress["current"]
|
||||
total = self.transfer_file_progress["total"]
|
||||
status_text = f"Transferring {current} of {total} files{dots}"
|
||||
else:
|
||||
status_text = f"Transferring{dots}"
|
||||
|
||||
|
||||
self._update_status_label(status_text)
|
||||
self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots
|
||||
# Schedule next update in 500ms
|
||||
self.animation_job = self.parent.after(500, self._animate_transfer_text)
|
||||
|
||||
|
||||
def _update_status_label(self, text: str) -> None:
|
||||
"""Update the status label with the given text.
|
||||
|
||||
|
||||
Args:
|
||||
text: Text to display in the status label
|
||||
"""
|
||||
if hasattr(self.parent, 'status_label'):
|
||||
if hasattr(self.parent, "status_label"):
|
||||
self.parent.status_label.config(text=text)
|
||||
self.parent.update_idletasks()
|
||||
|
||||
|
||||
def _reset_file_progress(self) -> None:
|
||||
"""Reset file progress tracking."""
|
||||
self.transfer_file_progress = {
|
||||
'current': 0,
|
||||
'total': 0,
|
||||
'active': False
|
||||
}
|
||||
|
||||
self.transfer_file_progress = {"current": 0, "total": 0, "active": False}
|
||||
|
||||
def is_animation_running(self) -> bool:
|
||||
"""Check if any animation is currently running.
|
||||
|
||||
|
||||
Returns:
|
||||
True if animation is running, False otherwise
|
||||
"""
|
||||
return self.animation_job is not None
|
||||
|
||||
|
||||
def is_scanning(self) -> bool:
|
||||
"""Check if scanning animation is active.
|
||||
|
||||
|
||||
Returns:
|
||||
True if scanning animation is active, False otherwise
|
||||
"""
|
||||
return self.scanning_active
|
||||
return self.scanning_active
|
||||
|
||||
+94
-73
@@ -21,8 +21,11 @@ try:
|
||||
from .handlers.animation_handler import AnimationHandler
|
||||
from .dialogs.dialog_manager import DialogManager
|
||||
from .components.ui_components import (
|
||||
PathSelectorFrame, DirectionSelector, StatusLabel,
|
||||
TransferButton, LicenseManager
|
||||
PathSelectorFrame,
|
||||
DirectionSelector,
|
||||
StatusLabel,
|
||||
TransferButton,
|
||||
LicenseManager,
|
||||
)
|
||||
from ..managers.device_manager import DeviceManager
|
||||
from ..managers.transfer_manager import TransferManager
|
||||
@@ -32,8 +35,11 @@ except ImportError:
|
||||
from gui.handlers.animation_handler import AnimationHandler
|
||||
from gui.dialogs.dialog_manager import DialogManager
|
||||
from gui.components.ui_components import (
|
||||
PathSelectorFrame, DirectionSelector, StatusLabel,
|
||||
TransferButton, LicenseManager
|
||||
PathSelectorFrame,
|
||||
DirectionSelector,
|
||||
StatusLabel,
|
||||
TransferButton,
|
||||
LicenseManager,
|
||||
)
|
||||
from managers.device_manager import DeviceManager
|
||||
from managers.transfer_manager import TransferManager
|
||||
@@ -45,26 +51,23 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
def __init__(self):
|
||||
"""Initialize the main GUI application."""
|
||||
super().__init__()
|
||||
|
||||
|
||||
# Transfer tracking for thread safety
|
||||
self.current_transfer_id = 0
|
||||
self.device_connected = False
|
||||
|
||||
|
||||
# Initialize modular components
|
||||
self.license_manager = LicenseManager(self)
|
||||
self.device_manager = DeviceManager(self) # Creates its own ADBManager
|
||||
self.dialog_manager = DialogManager(self)
|
||||
self.animation_handler = AnimationHandler(self)
|
||||
self.transfer_manager = TransferManager(
|
||||
self,
|
||||
self.device_manager,
|
||||
self.animation_handler,
|
||||
self.dialog_manager
|
||||
self, self.device_manager, self.animation_handler, self.dialog_manager
|
||||
)
|
||||
|
||||
|
||||
# Get ADB manager reference from device manager
|
||||
self.adb_manager = self.device_manager.adb_manager
|
||||
|
||||
|
||||
# Initialize UI and start application
|
||||
self._setup_initial_ui()
|
||||
|
||||
@@ -75,7 +78,7 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
self.geometry("520x320")
|
||||
self.minsize(520, 320)
|
||||
self.resizable(True, True)
|
||||
|
||||
|
||||
if self.license_manager.needs_license_agreement():
|
||||
# Show license agreement first
|
||||
self.license_manager.show_license_agreement(self._show_main_interface)
|
||||
@@ -88,7 +91,7 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
# Reset window size for main interface
|
||||
self.geometry("520x320")
|
||||
self.minsize(520, 320)
|
||||
|
||||
|
||||
# Setup main UI
|
||||
self._setup_main_ui()
|
||||
self._initialize_components()
|
||||
@@ -98,22 +101,18 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
"""Setup the main user interface."""
|
||||
# Direction selection
|
||||
self.direction_selector = DirectionSelector(self, self._on_direction_change)
|
||||
|
||||
|
||||
# Create a container frame for the path sections that can be reordered
|
||||
self.path_container = tk.Frame(self)
|
||||
self.path_container.pack(fill="x", padx=10, pady=(10, 0))
|
||||
|
||||
# Create path selector components
|
||||
self.android_path_selector = PathSelectorFrame(
|
||||
self.path_container,
|
||||
"Android device:",
|
||||
self.browse_remote_folder
|
||||
self.path_container, "Android device:", self.browse_remote_folder
|
||||
)
|
||||
|
||||
|
||||
self.computer_path_selector = PathSelectorFrame(
|
||||
self.path_container,
|
||||
"Computer:",
|
||||
self.browse_local_folder
|
||||
self.path_container, "Computer:", self.browse_local_folder
|
||||
)
|
||||
|
||||
# Initially arrange for pull (Android on top)
|
||||
@@ -124,7 +123,7 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
|
||||
# Transfer button with multi-mode functionality
|
||||
self.transfer_button = TransferButton(self)
|
||||
|
||||
|
||||
# Initially disable components until device is connected
|
||||
self._disable_browse_buttons()
|
||||
|
||||
@@ -134,20 +133,24 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
def _initialize_components(self):
|
||||
"""Initialize GUI components and handlers."""
|
||||
# Set up ADB callbacks directly
|
||||
self.adb_manager.set_progress_callback(lambda x: None) # Ignore progress for now
|
||||
self.adb_manager.set_progress_callback(
|
||||
lambda x: None
|
||||
) # Ignore progress for now
|
||||
self.adb_manager.set_status_callback(self._update_status)
|
||||
|
||||
|
||||
# Connect transfer manager callbacks
|
||||
self.transfer_manager.set_status_callback(self._update_status)
|
||||
self.transfer_manager.set_ui_callback('disable_controls', self.disable_controls)
|
||||
self.transfer_manager.set_ui_callback('enable_controls', self.enable_controls)
|
||||
self.transfer_manager.set_ui_callback('show_error', self.report_error)
|
||||
self.transfer_manager.set_ui_callback('show_stats', self._show_transfer_stats)
|
||||
self.transfer_manager.set_ui_callback('show_reminder', self._show_debugging_reminder)
|
||||
self.transfer_manager.set_ui_callback("disable_controls", self.disable_controls)
|
||||
self.transfer_manager.set_ui_callback("enable_controls", self.enable_controls)
|
||||
self.transfer_manager.set_ui_callback("show_error", self.report_error)
|
||||
self.transfer_manager.set_ui_callback("show_stats", self._show_transfer_stats)
|
||||
self.transfer_manager.set_ui_callback(
|
||||
"show_reminder", self._show_debugging_reminder
|
||||
)
|
||||
|
||||
def _update_status(self, message: str):
|
||||
"""Update the status label from any thread.
|
||||
|
||||
|
||||
Args:
|
||||
message: Status message to display
|
||||
"""
|
||||
@@ -166,7 +169,7 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
"""Validate selected paths and update button state accordingly."""
|
||||
android_path_valid = self.android_path_selector.is_path_selected()
|
||||
computer_path_valid = self.computer_path_selector.is_path_selected()
|
||||
|
||||
|
||||
if android_path_valid and computer_path_valid and self.device_connected:
|
||||
self.transfer_button.set_transfer_mode(self.start_transfer, enabled=True)
|
||||
else:
|
||||
@@ -193,25 +196,29 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
# Initialize ADB and check device connection
|
||||
self.device_manager.initialize_adb()
|
||||
self.device_connected = self.device_manager.check_device_connection()
|
||||
|
||||
|
||||
if self.device_connected:
|
||||
self._update_status("Status: Android device detected. Ready for file transfer.")
|
||||
self._update_status(
|
||||
"Status: Android device detected. Ready for file transfer."
|
||||
)
|
||||
self._enable_browse_buttons()
|
||||
self._validate_paths_and_update_button()
|
||||
else:
|
||||
self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.")
|
||||
self._update_status(
|
||||
"Status: No Android device detected. Please connect your device and enable USB debugging."
|
||||
)
|
||||
self.transfer_button.set_recheck_mode(self.recheck_device)
|
||||
|
||||
def _on_direction_change(self):
|
||||
"""Handle transfer direction change."""
|
||||
self._arrange_path_sections()
|
||||
|
||||
|
||||
def _arrange_path_sections(self):
|
||||
"""Arrange path sections based on transfer direction."""
|
||||
# Remove both sections first
|
||||
self.android_path_selector.pack_forget()
|
||||
self.computer_path_selector.pack_forget()
|
||||
|
||||
|
||||
if self.direction_selector.get_direction() == "pull":
|
||||
# Pull: Android (source) on top, Computer (destination) on bottom
|
||||
self.android_path_selector.pack(fill="x", pady=(0, 0))
|
||||
@@ -223,56 +230,63 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
|
||||
def browse_remote_folder(self):
|
||||
"""Open the Android file browser for remote path selection."""
|
||||
|
||||
def on_path_selected(path):
|
||||
self.android_path_selector.set_path(path)
|
||||
self._validate_paths_and_update_button()
|
||||
|
||||
browser = AndroidFileBrowser(self, self.adb_manager, on_path_selected)
|
||||
browser.show_browser(direction="pull")
|
||||
|
||||
|
||||
def browse_local_folder(self):
|
||||
"""Browse for local file or folder selection."""
|
||||
|
||||
def on_file_selected():
|
||||
filename = filedialog.askopenfilename(
|
||||
title="Select a file to transfer",
|
||||
initialdir=os.path.expanduser("~")
|
||||
title="Select a file to transfer", initialdir=os.path.expanduser("~")
|
||||
)
|
||||
if filename:
|
||||
self.computer_path_selector.set_path(filename)
|
||||
self._validate_paths_and_update_button()
|
||||
|
||||
|
||||
def on_folder_selected():
|
||||
foldername = filedialog.askdirectory(
|
||||
title="Select a folder to transfer",
|
||||
initialdir=os.path.expanduser("~")
|
||||
title="Select a folder to transfer", initialdir=os.path.expanduser("~")
|
||||
)
|
||||
if foldername:
|
||||
self.computer_path_selector.set_path(foldername)
|
||||
self._validate_paths_and_update_button()
|
||||
|
||||
|
||||
# Show file/folder selection dialog
|
||||
self.dialog_manager.show_file_folder_choice(on_file_selected, on_folder_selected)
|
||||
self.dialog_manager.show_file_folder_choice(
|
||||
on_file_selected, on_folder_selected
|
||||
)
|
||||
|
||||
def recheck_device(self):
|
||||
"""Recheck for connected Android device."""
|
||||
self.transfer_button.set_checking_mode()
|
||||
self.animation_handler.start_scanning_animation("Status: Scanning")
|
||||
|
||||
|
||||
def perform_recheck():
|
||||
self.device_connected = self.device_manager.check_device_connection()
|
||||
self.after(0, self._handle_device_recheck_result)
|
||||
|
||||
|
||||
threading.Thread(target=perform_recheck, daemon=True).start()
|
||||
|
||||
def _handle_device_recheck_result(self):
|
||||
"""Handle the result of device recheck."""
|
||||
self.animation_handler.stop_animation()
|
||||
|
||||
|
||||
if self.device_connected:
|
||||
self._update_status("Status: Android device detected. Ready for file transfer.")
|
||||
self._update_status(
|
||||
"Status: Android device detected. Ready for file transfer."
|
||||
)
|
||||
self._enable_browse_buttons()
|
||||
self._validate_paths_and_update_button()
|
||||
else:
|
||||
self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.")
|
||||
self._update_status(
|
||||
"Status: No Android device detected. Please connect your device and enable USB debugging."
|
||||
)
|
||||
self.transfer_button.set_recheck_mode(self.recheck_device)
|
||||
|
||||
def start_transfer(self):
|
||||
@@ -282,34 +296,34 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
direction = self.direction_selector.get_direction()
|
||||
remote_path = self.android_path_selector.get_path()
|
||||
local_path = self.computer_path_selector.get_path()
|
||||
|
||||
|
||||
# Validate paths
|
||||
if not self.android_path_selector.is_path_selected():
|
||||
messagebox.showerror("Error", "Please select an Android device path.")
|
||||
return
|
||||
|
||||
|
||||
if not self.computer_path_selector.is_path_selected():
|
||||
messagebox.showerror("Error", "Please select a computer path.")
|
||||
return
|
||||
|
||||
|
||||
# Check device connection
|
||||
if not self.device_manager.check_device_connection():
|
||||
messagebox.showerror("Error", "Android device not connected. Please check your connection and try again.")
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Android device not connected. Please check your connection and try again.",
|
||||
)
|
||||
self._handle_device_disconnection()
|
||||
return
|
||||
|
||||
|
||||
# Switch to cancel mode and start transfer
|
||||
self.transfer_button.set_cancel_mode(self.cancel_transfer)
|
||||
self.animation_handler.start_transfer_animation("Status: Transferring")
|
||||
|
||||
|
||||
# Start transfer using transfer manager
|
||||
self.transfer_manager.start_transfer(
|
||||
direction,
|
||||
remote_path,
|
||||
local_path,
|
||||
self._on_transfer_complete
|
||||
direction, remote_path, local_path, self._on_transfer_complete
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.report_error(f"Error starting transfer: {str(e)}")
|
||||
|
||||
@@ -321,23 +335,28 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
self._update_status("Status: Transfer cancelled by user.")
|
||||
self.enable_controls()
|
||||
self._validate_paths_and_update_button()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.report_error(f"Error cancelling transfer: {str(e)}")
|
||||
|
||||
def _on_transfer_complete(self, success: bool, stats: dict, operation: str):
|
||||
"""Handle transfer completion."""
|
||||
self.animation_handler.stop_animation()
|
||||
|
||||
|
||||
if success:
|
||||
self._update_status(f"Status: Transfer complete! Successfully {operation}.")
|
||||
if stats:
|
||||
self._show_transfer_stats(stats, operation)
|
||||
self._show_debugging_reminder()
|
||||
else:
|
||||
self._update_status("Status: Transfer failed. Please check your connection and try again.")
|
||||
messagebox.showerror("Transfer Failed", "The file transfer was not successful. Please check your device connection and try again.")
|
||||
|
||||
self._update_status(
|
||||
"Status: Transfer failed. Please check your connection and try again."
|
||||
)
|
||||
messagebox.showerror(
|
||||
"Transfer Failed",
|
||||
"The file transfer was not successful. Please check your device connection and try again.",
|
||||
)
|
||||
|
||||
self.enable_controls()
|
||||
self._validate_paths_and_update_button()
|
||||
|
||||
@@ -347,7 +366,9 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
self._clear_paths_and_disable_button()
|
||||
self._disable_browse_buttons()
|
||||
self.transfer_button.set_recheck_mode(self.recheck_device)
|
||||
self._update_status("Status: Device disconnected. Please reconnect and enable USB debugging.")
|
||||
self._update_status(
|
||||
"Status: Device disconnected. Please reconnect and enable USB debugging."
|
||||
)
|
||||
|
||||
def disable_controls(self):
|
||||
"""Disable UI controls during transfer."""
|
||||
@@ -366,7 +387,7 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
def _disable_controls_ui(self):
|
||||
"""Disable controls on UI thread."""
|
||||
self._disable_browse_buttons()
|
||||
|
||||
|
||||
def _enable_controls_ui(self):
|
||||
"""Enable controls on UI thread."""
|
||||
self._enable_browse_buttons()
|
||||
@@ -396,13 +417,13 @@ class AndroidFileHandlerGUI(tk.Tk):
|
||||
"""Handle window close event."""
|
||||
try:
|
||||
# Cancel any ongoing transfers
|
||||
if hasattr(self, 'transfer_manager'):
|
||||
if hasattr(self, "transfer_manager"):
|
||||
self.transfer_manager.cancel_transfer()
|
||||
|
||||
|
||||
# Stop any animations
|
||||
if hasattr(self, 'animation_handler'):
|
||||
if hasattr(self, "animation_handler"):
|
||||
self.animation_handler.stop_animation()
|
||||
|
||||
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
print(f"Error during close: {e}")
|
||||
@@ -416,4 +437,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
+22
-13
@@ -11,18 +11,25 @@ class ProgressHandler:
|
||||
"""Handles progress bar updates and status messages."""
|
||||
|
||||
def __init__(
|
||||
self, parent_widget: tk.Widget, progress_bar: ttk.Progressbar, status_label: tk.Label
|
||||
self,
|
||||
parent_widget: tk.Widget,
|
||||
progress_bar: ttk.Progressbar,
|
||||
status_label: tk.Label,
|
||||
) -> None:
|
||||
"""Initialize the progress handler."""
|
||||
self.parent = parent_widget
|
||||
self.progress_bar = progress_bar # Don't call as function - it's already created
|
||||
self.progress_bar = (
|
||||
progress_bar # Don't call as function - it's already created
|
||||
)
|
||||
self.status_label = status_label
|
||||
self._last_percentage: float = 0.0
|
||||
self._transfer_active: bool = False # Track if a transfer is actually active
|
||||
|
||||
def update_progress(self, bytes_transferred_or_percentage, bytes_total=None) -> None:
|
||||
def update_progress(
|
||||
self, bytes_transferred_or_percentage, bytes_total=None
|
||||
) -> None:
|
||||
"""Update the progress bar (thread-safe).
|
||||
|
||||
|
||||
Args:
|
||||
bytes_transferred_or_percentage: Either bytes transferred (if bytes_total provided) or percentage (0-100)
|
||||
bytes_total: Total number of bytes to transfer (optional)
|
||||
@@ -37,10 +44,10 @@ class ProgressHandler:
|
||||
else:
|
||||
# Called with just percentage
|
||||
percentage = float(bytes_transferred_or_percentage)
|
||||
|
||||
|
||||
# Ensure percentage is within valid range
|
||||
percentage = max(0.0, min(100.0, percentage))
|
||||
|
||||
|
||||
# Schedule UI update on main thread using a proper closure
|
||||
def update_ui() -> None:
|
||||
self._update_progress_ui(percentage)
|
||||
@@ -49,15 +56,15 @@ class ProgressHandler:
|
||||
|
||||
def _update_progress_ui(self, percentage: float) -> None:
|
||||
"""Internal method to update progress bar on main thread.
|
||||
|
||||
|
||||
Args:
|
||||
percentage: Progress percentage (0.0 to 100.0) - used for logging only in indeterminate mode
|
||||
"""
|
||||
|
||||
|
||||
# Only log significant progress jumps (10% or more)
|
||||
if abs(percentage - self._last_percentage) >= 10.0:
|
||||
pass # Could add non-debug logging here if needed
|
||||
|
||||
|
||||
self._last_percentage = percentage
|
||||
|
||||
try:
|
||||
@@ -67,13 +74,14 @@ class ProgressHandler:
|
||||
elif percentage >= 100:
|
||||
self.progress_bar.stop() # Stop animation when complete
|
||||
self._transfer_active = False # Transfer is done
|
||||
|
||||
|
||||
self.parent.update_idletasks()
|
||||
except Exception as exception_error:
|
||||
pass # Silent error handling
|
||||
|
||||
def reset_progress(self) -> None:
|
||||
"""Reset progress bar to 0 (thread-safe)."""
|
||||
|
||||
def update_ui() -> None:
|
||||
try:
|
||||
self.progress_bar.stop() # Stop any animation
|
||||
@@ -91,10 +99,11 @@ class ProgressHandler:
|
||||
|
||||
def set_status(self, message: str) -> None:
|
||||
"""Set the status label text (thread-safe).
|
||||
|
||||
|
||||
Args:
|
||||
message: The status message to display
|
||||
"""
|
||||
|
||||
# Schedule UI update on main thread
|
||||
def update_ui() -> None:
|
||||
self._set_status_ui(message)
|
||||
@@ -103,7 +112,7 @@ class ProgressHandler:
|
||||
|
||||
def _set_status_ui(self, message: str) -> None:
|
||||
"""Internal method to set status label on main thread.
|
||||
|
||||
|
||||
Args:
|
||||
message: The status message to display
|
||||
"""
|
||||
@@ -111,4 +120,4 @@ class ProgressHandler:
|
||||
self.status_label.config(text=message)
|
||||
self.parent.update_idletasks()
|
||||
except Exception as exception_error:
|
||||
pass # Silent error handling
|
||||
pass # Silent error handling
|
||||
|
||||
@@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application.
|
||||
|
||||
try:
|
||||
from gui.main_window import main
|
||||
from gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||
except ImportError:
|
||||
from .gui.main_window import main
|
||||
from .gui.dialogs.license_agreement import run_windows_first_run_if_needed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_windows_first_run_if_needed()
|
||||
main()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
Managers Package
|
||||
Business logic managers for device and transfer operations.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -15,10 +15,14 @@ except ImportError:
|
||||
|
||||
class DeviceManager:
|
||||
"""Manages Android device connections and ADB operations."""
|
||||
|
||||
def __init__(self, parent_window: tk.Tk, status_callback: Optional[Callable[[str], None]] = None):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_window: tk.Tk,
|
||||
status_callback: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
"""Initialize the device manager.
|
||||
|
||||
|
||||
Args:
|
||||
parent_window: The main window instance
|
||||
status_callback: Callback function for status updates
|
||||
@@ -27,14 +31,14 @@ class DeviceManager:
|
||||
self.status_callback = status_callback
|
||||
self.adb_manager = ADBManager()
|
||||
self.device_connected = False
|
||||
|
||||
|
||||
# Set up ADB callbacks
|
||||
self.adb_manager.set_status_callback(self._on_adb_status_update)
|
||||
self.adb_manager.set_progress_callback(self._on_adb_progress_update)
|
||||
|
||||
|
||||
def initialize_adb(self) -> bool:
|
||||
"""Initialize ADB and download tools if needed.
|
||||
|
||||
|
||||
Returns:
|
||||
True if ADB is available and ready, False otherwise
|
||||
"""
|
||||
@@ -46,14 +50,14 @@ class DeviceManager:
|
||||
"and speeds up file transfers over USB between computers and Android devices. "
|
||||
"Please do not delete or move the platform-tools folder that will be "
|
||||
"downloaded. These are tools written by Google "
|
||||
"and they are required for this application to function properly."
|
||||
"and they are required for this application to function properly.",
|
||||
)
|
||||
|
||||
|
||||
self._update_status("ADB not found locally. Downloading...")
|
||||
self.parent.update()
|
||||
self.adb_manager.download_and_extract_adb()
|
||||
success = is_adb_available()
|
||||
|
||||
|
||||
if success:
|
||||
self._update_status("ADB downloaded and ready.")
|
||||
return True
|
||||
@@ -64,18 +68,18 @@ class DeviceManager:
|
||||
)
|
||||
messagebox.showerror("Error", "Failed to download ADB tools. Exiting.")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_device_connection(self) -> Optional[str]:
|
||||
"""Check for device connection and update status.
|
||||
|
||||
|
||||
Returns:
|
||||
Device ID if connected, None otherwise
|
||||
"""
|
||||
self._update_status("Checking for connected device...")
|
||||
self.parent.update()
|
||||
|
||||
|
||||
device = self.adb_manager.check_device()
|
||||
if device:
|
||||
self.device_connected = True
|
||||
@@ -88,34 +92,36 @@ class DeviceManager:
|
||||
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def is_remote_file(self, remote_path: str) -> bool:
|
||||
"""Check if the remote path points to a file (not a directory).
|
||||
|
||||
|
||||
Args:
|
||||
remote_path: Path on the Android device
|
||||
|
||||
|
||||
Returns:
|
||||
True if it's a file, False if it's a directory or check fails
|
||||
"""
|
||||
try:
|
||||
result = self.adb_manager.run_adb_command(["shell", "ls", "-la", remote_path])
|
||||
result = self.adb_manager.run_adb_command(
|
||||
["shell", "ls", "-la", remote_path]
|
||||
)
|
||||
if isinstance(result, tuple) and len(result) == 3:
|
||||
stdout, stderr, returncode = result
|
||||
if returncode == 0 and stdout:
|
||||
# If the output starts with '-', it's a regular file
|
||||
return stdout.strip().startswith('-')
|
||||
return stdout.strip().startswith("-")
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_file_transfer_methods(self, direction: str, is_file: bool):
|
||||
"""Get the appropriate transfer methods based on direction and type.
|
||||
|
||||
|
||||
Args:
|
||||
direction: Transfer direction ('pull' or 'push')
|
||||
is_file: Whether transferring a file (True) or folder (False)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (transfer_method, transfer_type)
|
||||
"""
|
||||
@@ -129,44 +135,44 @@ class DeviceManager:
|
||||
return self.adb_manager.push_file, "file"
|
||||
else:
|
||||
return self.adb_manager.push_folder_with_dedup, "folder"
|
||||
|
||||
|
||||
def cancel_current_operation(self) -> None:
|
||||
"""Cancel the current ADB operation."""
|
||||
try:
|
||||
self.adb_manager.cancel_current_operation()
|
||||
except Exception as e:
|
||||
print(f"Error cancelling operation: {e}")
|
||||
|
||||
|
||||
def cancel_transfer(self) -> bool:
|
||||
"""Cancel the current transfer operation.
|
||||
|
||||
|
||||
Returns:
|
||||
True if transfer was cancelled successfully, False otherwise
|
||||
"""
|
||||
return self.adb_manager.cancel_transfer()
|
||||
|
||||
|
||||
def _update_status(self, message: str) -> None:
|
||||
"""Update status through callback if available.
|
||||
|
||||
|
||||
Args:
|
||||
message: Status message to display
|
||||
"""
|
||||
if self.status_callback:
|
||||
self.status_callback(message)
|
||||
|
||||
|
||||
def _on_adb_status_update(self, message: str) -> None:
|
||||
"""Handle status updates from ADB manager.
|
||||
|
||||
|
||||
Args:
|
||||
message: Status message from ADB operations
|
||||
"""
|
||||
self._update_status(message)
|
||||
|
||||
|
||||
def _on_adb_progress_update(self, percentage: int) -> None:
|
||||
"""Handle progress updates from ADB manager.
|
||||
|
||||
|
||||
Args:
|
||||
percentage: Progress percentage (0-100)
|
||||
"""
|
||||
# Currently we ignore progress updates and rely on status updates
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -19,11 +19,16 @@ except ImportError:
|
||||
|
||||
class TransferManager:
|
||||
"""Manages file transfer operations and coordination."""
|
||||
|
||||
def __init__(self, parent_window, device_manager: DeviceManager,
|
||||
animation_handler: AnimationHandler, dialog_manager: DialogManager):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_window,
|
||||
device_manager: DeviceManager,
|
||||
animation_handler: AnimationHandler,
|
||||
dialog_manager: DialogManager,
|
||||
):
|
||||
"""Initialize the transfer manager.
|
||||
|
||||
|
||||
Args:
|
||||
parent_window: The main window instance
|
||||
device_manager: Device manager instance
|
||||
@@ -34,41 +39,46 @@ class TransferManager:
|
||||
self.device_manager = device_manager
|
||||
self.animation_handler = animation_handler
|
||||
self.dialog_manager = dialog_manager
|
||||
|
||||
|
||||
# Transfer tracking
|
||||
self.current_transfer_id = 0
|
||||
|
||||
|
||||
# UI callbacks
|
||||
self.ui_callbacks = {}
|
||||
|
||||
|
||||
def set_ui_callback(self, name: str, callback: Callable) -> None:
|
||||
"""Set a UI callback function.
|
||||
|
||||
|
||||
Args:
|
||||
name: Name of the callback
|
||||
callback: Function to call
|
||||
"""
|
||||
self.ui_callbacks[name] = callback
|
||||
|
||||
|
||||
def set_status_callback(self, callback: Callable[[str], None]) -> None:
|
||||
"""Set the status update callback.
|
||||
|
||||
|
||||
Args:
|
||||
callback: Function to call for status updates
|
||||
"""
|
||||
self.ui_callbacks['status'] = callback
|
||||
self.ui_callbacks["status"] = callback
|
||||
self.status_callback = callback
|
||||
|
||||
|
||||
def set_controls_callback(self, callback: Callable[[], None]) -> None:
|
||||
"""Set the controls update callback.
|
||||
|
||||
|
||||
Args:
|
||||
callback: Function to call for enabling/disabling controls
|
||||
"""
|
||||
self.controls_callback = callback
|
||||
|
||||
def start_transfer(self, direction: str, source_path: str, dest_path: str,
|
||||
completion_callback: Optional[Callable] = None) -> bool:
|
||||
|
||||
def start_transfer(
|
||||
self,
|
||||
direction: str,
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
completion_callback: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Start a file transfer operation.
|
||||
|
||||
Args:
|
||||
@@ -91,18 +101,18 @@ class TransferManager:
|
||||
is_file = os.path.isfile(source_path)
|
||||
|
||||
# Disable controls during transfer
|
||||
if 'disable_controls' in self.ui_callbacks:
|
||||
self.ui_callbacks['disable_controls']()
|
||||
if "disable_controls" in self.ui_callbacks:
|
||||
self.ui_callbacks["disable_controls"]()
|
||||
|
||||
# Start transfer in background thread
|
||||
transfer_thread = threading.Thread(
|
||||
target=self._transfer_thread,
|
||||
args=(direction, source_path, dest_path, transfer_id, is_file),
|
||||
daemon=True
|
||||
daemon=True,
|
||||
)
|
||||
transfer_thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _is_remote_file(self, remote_path: str) -> bool:
|
||||
"""Check if a remote path is a file.
|
||||
|
||||
@@ -121,63 +131,76 @@ class TransferManager:
|
||||
|
||||
def cancel_transfer(self) -> bool:
|
||||
"""Cancel the current transfer operation.
|
||||
|
||||
|
||||
Returns:
|
||||
True if transfer was cancelled successfully, False otherwise
|
||||
"""
|
||||
# Cancel the actual ADB process
|
||||
cancelled = self.device_manager.cancel_transfer()
|
||||
|
||||
|
||||
# Increment transfer ID to invalidate current transfer
|
||||
self.current_transfer_id += 1
|
||||
|
||||
|
||||
# Stop animation and restore UI
|
||||
self.animation_handler.stop_animation()
|
||||
|
||||
|
||||
# Update status
|
||||
status = "Transfer cancelled by user." if cancelled else "Transfer cancellation failed."
|
||||
status = (
|
||||
"Transfer cancelled by user."
|
||||
if cancelled
|
||||
else "Transfer cancellation failed."
|
||||
)
|
||||
self._update_status(status)
|
||||
|
||||
|
||||
return cancelled
|
||||
|
||||
def _validate_transfer_paths(self, direction: str, source_path: str, dest_path: str) -> bool:
|
||||
|
||||
def _validate_transfer_paths(
|
||||
self, direction: str, source_path: str, dest_path: str
|
||||
) -> bool:
|
||||
"""Validate transfer paths based on direction.
|
||||
|
||||
|
||||
Args:
|
||||
direction: Transfer direction ('pull' or 'push')
|
||||
source_path: Source path
|
||||
dest_path: Destination path
|
||||
|
||||
|
||||
Returns:
|
||||
True if paths are valid, False otherwise
|
||||
"""
|
||||
if not source_path or not dest_path:
|
||||
self.dialog_manager.show_error("Input Error", "Both source and destination paths are required.")
|
||||
self.dialog_manager.show_error(
|
||||
"Input Error", "Both source and destination paths are required."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
if direction == "push":
|
||||
# For push operations, validate local source path exists
|
||||
if not os.path.exists(source_path):
|
||||
self.dialog_manager.show_error(
|
||||
"Input Error",
|
||||
f"Source path does not exist: {source_path}"
|
||||
"Input Error", f"Source path does not exist: {source_path}"
|
||||
)
|
||||
return False
|
||||
else: # pull operations
|
||||
# For pull operations, validate local destination is a directory
|
||||
if not os.path.isdir(dest_path):
|
||||
self.dialog_manager.show_error(
|
||||
"Input Error",
|
||||
"Destination must be a valid directory for pulled files."
|
||||
"Input Error",
|
||||
"Destination must be a valid directory for pulled files.",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
def _transfer_thread(self, direction: str, source_path: str, dest_path: str,
|
||||
transfer_id: int, is_file: bool) -> None:
|
||||
|
||||
def _transfer_thread(
|
||||
self,
|
||||
direction: str,
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
transfer_id: int,
|
||||
is_file: bool,
|
||||
) -> None:
|
||||
"""Background thread function for transfer operations.
|
||||
|
||||
|
||||
Args:
|
||||
direction: Transfer direction ('pull' or 'push')
|
||||
source_path: Source path
|
||||
@@ -189,7 +212,7 @@ class TransferManager:
|
||||
# Check if this transfer is still current
|
||||
if self.current_transfer_id != transfer_id:
|
||||
return
|
||||
|
||||
|
||||
# Recheck device connectivity before proceeding
|
||||
device = self.device_manager.adb_manager.check_device()
|
||||
if not device:
|
||||
@@ -197,15 +220,22 @@ class TransferManager:
|
||||
self.animation_handler.stop_animation()
|
||||
self.parent.after(0, self._handle_device_disconnection)
|
||||
return
|
||||
|
||||
|
||||
# Get the appropriate transfer method
|
||||
transfer_method, transfer_type = self.device_manager.get_file_transfer_methods(direction, is_file)
|
||||
|
||||
transfer_method, transfer_type = (
|
||||
self.device_manager.get_file_transfer_methods(direction, is_file)
|
||||
)
|
||||
|
||||
# Perform the transfer
|
||||
success, stats = self._execute_transfer(
|
||||
direction, source_path, dest_path, transfer_method, transfer_type, is_file
|
||||
direction,
|
||||
source_path,
|
||||
dest_path,
|
||||
transfer_method,
|
||||
transfer_type,
|
||||
is_file,
|
||||
)
|
||||
|
||||
|
||||
if success and self.current_transfer_id == transfer_id:
|
||||
self.animation_handler.stop_animation()
|
||||
transfer_desc = "File" if is_file else "Folder"
|
||||
@@ -213,31 +243,50 @@ class TransferManager:
|
||||
f"{transfer_desc} transfer completed successfully. "
|
||||
"To start another transfer, please select another file or folder."
|
||||
)
|
||||
|
||||
|
||||
# Show debugging reminder and transfer statistics
|
||||
self.parent.after(0, self.dialog_manager.show_disable_debugging_reminder)
|
||||
|
||||
self.parent.after(
|
||||
0, self.dialog_manager.show_disable_debugging_reminder
|
||||
)
|
||||
|
||||
if stats is not None:
|
||||
self.parent.after(0, lambda: self.dialog_manager.show_transfer_stats(
|
||||
stats, direction.capitalize(), self.device_manager.adb_manager.deduplicator
|
||||
))
|
||||
|
||||
self.parent.after(
|
||||
0,
|
||||
lambda: self.dialog_manager.show_transfer_stats(
|
||||
stats,
|
||||
direction.capitalize(),
|
||||
self.device_manager.adb_manager.deduplicator,
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.current_transfer_id == transfer_id:
|
||||
self.animation_handler.stop_animation()
|
||||
transfer_desc = "file" if is_file else "folder"
|
||||
error_msg = f"{direction.capitalize()} {transfer_desc} operation failed: {e}"
|
||||
self.parent.after(0, lambda: self.dialog_manager.show_error("Transfer Error", error_msg))
|
||||
error_msg = (
|
||||
f"{direction.capitalize()} {transfer_desc} operation failed: {e}"
|
||||
)
|
||||
self.parent.after(
|
||||
0,
|
||||
lambda: self.dialog_manager.show_error("Transfer Error", error_msg),
|
||||
)
|
||||
finally:
|
||||
if self.current_transfer_id == transfer_id:
|
||||
self.animation_handler.stop_animation()
|
||||
if self.controls_callback:
|
||||
self.parent.after(0, self.controls_callback)
|
||||
|
||||
def _execute_transfer(self, direction: str, source_path: str, dest_path: str,
|
||||
transfer_method: Callable, transfer_type: str, is_file: bool) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
||||
|
||||
def _execute_transfer(
|
||||
self,
|
||||
direction: str,
|
||||
source_path: str,
|
||||
dest_path: str,
|
||||
transfer_method: Callable,
|
||||
transfer_type: str,
|
||||
is_file: bool,
|
||||
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
||||
"""Execute the actual transfer operation.
|
||||
|
||||
|
||||
Args:
|
||||
direction: Transfer direction
|
||||
source_path: Source path
|
||||
@@ -245,7 +294,7 @@ class TransferManager:
|
||||
transfer_method: Transfer method to call
|
||||
transfer_type: Type of transfer ('file' or 'folder')
|
||||
is_file: True if transferring a file
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, stats_dict or None)
|
||||
"""
|
||||
@@ -264,7 +313,7 @@ class TransferManager:
|
||||
# File transfers return just success
|
||||
success = transfer_method(source_path, dest_path)
|
||||
return success, None
|
||||
|
||||
|
||||
def _handle_device_disconnection(self) -> None:
|
||||
"""Handle device disconnection during transfers."""
|
||||
self.device_manager.device_connected = False
|
||||
@@ -273,19 +322,19 @@ class TransferManager:
|
||||
"securely inserted, USB debugging is enabled, and that File Transfer mode is turned on."
|
||||
)
|
||||
self.dialog_manager.show_enable_debugging_instructions()
|
||||
|
||||
|
||||
def _update_status(self, message: str) -> None:
|
||||
"""Update status through callback if available.
|
||||
|
||||
|
||||
Args:
|
||||
message: Status message to display
|
||||
"""
|
||||
if self.status_callback:
|
||||
self.status_callback(message)
|
||||
|
||||
|
||||
def handle_adb_status_update(self, message: str) -> None:
|
||||
"""Handle status updates from ADB operations.
|
||||
|
||||
|
||||
Args:
|
||||
message: Status message from ADB
|
||||
"""
|
||||
@@ -297,10 +346,13 @@ class TransferManager:
|
||||
total = int(parts[2])
|
||||
self.animation_handler.update_transfer_progress(current, total)
|
||||
return
|
||||
|
||||
|
||||
# Check for animation state changes
|
||||
if self.animation_handler.is_animation_running():
|
||||
if "Scanning for duplicates" in message and not self.animation_handler.is_scanning():
|
||||
if (
|
||||
"Scanning for duplicates" in message
|
||||
and not self.animation_handler.is_scanning()
|
||||
):
|
||||
# Already scanning, ignore duplicate messages
|
||||
return
|
||||
elif "Starting transfer" in message or "Transferring" in message:
|
||||
@@ -308,9 +360,11 @@ class TransferManager:
|
||||
self.animation_handler.stop_animation()
|
||||
self.animation_handler.start_transfer_animation()
|
||||
return
|
||||
elif ("Duplicate scan complete" in message or
|
||||
"No duplicates found" in message or
|
||||
"All files already exist" in message):
|
||||
elif (
|
||||
"Duplicate scan complete" in message
|
||||
or "No duplicates found" in message
|
||||
or "All files already exist" in message
|
||||
):
|
||||
# Allow these messages to show briefly before transfer starts
|
||||
self.animation_handler.stop_animation()
|
||||
self._update_status(message)
|
||||
@@ -322,6 +376,6 @@ class TransferManager:
|
||||
# Start scanning animation
|
||||
self.animation_handler.start_scanning_animation()
|
||||
return
|
||||
|
||||
|
||||
# Normal status update
|
||||
self._update_status(message)
|
||||
self._update_status(message)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
Utils Package
|
||||
Utility functions and helper modules.
|
||||
"""
|
||||
"""
|
||||
|
||||
+132
-88
@@ -10,44 +10,49 @@ from typing import Optional, Dict, List, Tuple, Callable
|
||||
|
||||
class FileDeduplicator:
|
||||
"""Handles file hash computation and duplicate detection."""
|
||||
|
||||
def __init__(self, status_callback: Optional[Callable[[str], None]] = None,
|
||||
progress_callback: Optional[Callable[[int], None]] = None):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_callback: Optional[Callable[[str], None]] = None,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
):
|
||||
"""Initialize the deduplicator with optional callbacks.
|
||||
|
||||
|
||||
Args:
|
||||
status_callback: Function to call with status updates
|
||||
progress_callback: Function to call with progress updates (0-100)
|
||||
"""
|
||||
self.status_callback = status_callback
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
|
||||
def _update_status(self, message: str) -> None:
|
||||
"""Update status if callback is available."""
|
||||
if self.status_callback:
|
||||
self.status_callback(message)
|
||||
|
||||
|
||||
def _update_progress(self, percentage: int) -> None:
|
||||
"""Update progress if callback is available."""
|
||||
if self.progress_callback:
|
||||
self.progress_callback(percentage)
|
||||
|
||||
def compute_local_file_hash(self, file_path: str, algorithm: str = 'sha256') -> Optional[str]:
|
||||
|
||||
def compute_local_file_hash(
|
||||
self, file_path: str, algorithm: str = "sha256"
|
||||
) -> Optional[str]:
|
||||
"""Compute hash of a local file.
|
||||
|
||||
|
||||
Args:
|
||||
file_path: Path to the local file
|
||||
algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256')
|
||||
|
||||
|
||||
Returns:
|
||||
Hex digest of the file hash, or None if error
|
||||
"""
|
||||
try:
|
||||
if not os.path.isfile(file_path):
|
||||
return None
|
||||
|
||||
|
||||
hash_obj = hashlib.new(algorithm)
|
||||
with open(file_path, 'rb') as file_handle:
|
||||
with open(file_path, "rb") as file_handle:
|
||||
# Read in chunks to handle large files efficiently
|
||||
for chunk in iter(lambda: file_handle.read(8192), b""):
|
||||
hash_obj.update(chunk)
|
||||
@@ -56,139 +61,162 @@ class FileDeduplicator:
|
||||
self._update_status(f"Error computing hash for {file_path}: {exception}")
|
||||
return None
|
||||
|
||||
def compute_remote_file_hash(self, remote_path: str, algorithm: str = 'sha256',
|
||||
adb_command_runner: Optional[Callable] = None) -> Optional[str]:
|
||||
def compute_remote_file_hash(
|
||||
self,
|
||||
remote_path: str,
|
||||
algorithm: str = "sha256",
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
) -> Optional[str]:
|
||||
"""Compute hash of a file on the Android device using ADB shell.
|
||||
|
||||
|
||||
Args:
|
||||
remote_path: Path to the file on the Android device
|
||||
algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256')
|
||||
adb_command_runner: Function that runs ADB commands and returns (stdout, stderr, returncode)
|
||||
|
||||
|
||||
Returns:
|
||||
Hex digest of the file hash, or None if error
|
||||
"""
|
||||
if not adb_command_runner:
|
||||
self._update_status("No ADB command runner provided")
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
# Map algorithm names to Android shell commands
|
||||
hash_commands = {
|
||||
'md5': 'md5sum',
|
||||
'sha1': 'sha1sum',
|
||||
'sha256': 'sha256sum'
|
||||
}
|
||||
|
||||
hash_commands = {"md5": "md5sum", "sha1": "sha1sum", "sha256": "sha256sum"}
|
||||
|
||||
if algorithm not in hash_commands:
|
||||
self._update_status(f"Unsupported hash algorithm: {algorithm}")
|
||||
return None
|
||||
|
||||
cmd = ['shell', hash_commands[algorithm], remote_path]
|
||||
|
||||
cmd = ["shell", hash_commands[algorithm], remote_path]
|
||||
stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True)
|
||||
|
||||
|
||||
if returncode != 0 or not stdout:
|
||||
self._update_status(f"Failed to compute remote hash: {stderr}")
|
||||
return None
|
||||
|
||||
|
||||
# Parse output: "hash_value filename"
|
||||
hash_value = stdout.split()[0] if stdout else None
|
||||
return hash_value.lower() if hash_value else None
|
||||
|
||||
|
||||
except Exception as exception:
|
||||
self._update_status(f"Error computing remote hash for {remote_path}: {exception}")
|
||||
self._update_status(
|
||||
f"Error computing remote hash for {remote_path}: {exception}"
|
||||
)
|
||||
return None
|
||||
|
||||
def check_files_identical(self, local_path: str, remote_path: str,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = 'sha256') -> bool:
|
||||
def check_files_identical(
|
||||
self,
|
||||
local_path: str,
|
||||
remote_path: str,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = "sha256",
|
||||
) -> bool:
|
||||
"""Check if local and remote files are identical by comparing hashes.
|
||||
|
||||
|
||||
Args:
|
||||
local_path: Path to the local file
|
||||
remote_path: Path to the remote file
|
||||
adb_command_runner: Function that runs ADB commands
|
||||
algorithm: Hash algorithm to use
|
||||
|
||||
|
||||
Returns:
|
||||
True if files are identical, False otherwise
|
||||
"""
|
||||
local_hash = self.compute_local_file_hash(local_path, algorithm)
|
||||
remote_hash = self.compute_remote_file_hash(remote_path, algorithm, adb_command_runner)
|
||||
|
||||
remote_hash = self.compute_remote_file_hash(
|
||||
remote_path, algorithm, adb_command_runner
|
||||
)
|
||||
|
||||
if local_hash is None or remote_hash is None:
|
||||
return False
|
||||
|
||||
|
||||
return local_hash == remote_hash
|
||||
|
||||
def build_local_file_hash_map(self, file_paths: List[str],
|
||||
algorithm: str = 'sha256') -> Dict[str, str]:
|
||||
def build_local_file_hash_map(
|
||||
self, file_paths: List[str], algorithm: str = "sha256"
|
||||
) -> Dict[str, str]:
|
||||
"""Build a mapping of local file paths to their hashes.
|
||||
|
||||
|
||||
Args:
|
||||
file_paths: List of local file paths to hash
|
||||
algorithm: Hash algorithm to use
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to their hash values
|
||||
"""
|
||||
hash_map = {}
|
||||
total_files = len(file_paths)
|
||||
|
||||
|
||||
for index, file_path in enumerate(file_paths):
|
||||
file_hash = self.compute_local_file_hash(file_path, algorithm)
|
||||
|
||||
|
||||
if file_hash:
|
||||
hash_map[file_path] = file_hash
|
||||
|
||||
|
||||
# Update progress
|
||||
if total_files > 0:
|
||||
progress_percentage = int((index + 1) * 100 / total_files)
|
||||
self._update_progress(progress_percentage)
|
||||
self._update_status(f"Computing local hashes... {index + 1}/{total_files}")
|
||||
|
||||
self._update_status(
|
||||
f"Computing local hashes... {index + 1}/{total_files}"
|
||||
)
|
||||
|
||||
return hash_map
|
||||
|
||||
def build_remote_file_hash_map(self, file_paths: List[str],
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = 'sha256') -> Dict[str, str]:
|
||||
def build_remote_file_hash_map(
|
||||
self,
|
||||
file_paths: List[str],
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = "sha256",
|
||||
) -> Dict[str, str]:
|
||||
"""Build a mapping of remote file paths to their hashes.
|
||||
|
||||
|
||||
Args:
|
||||
file_paths: List of remote file paths to hash
|
||||
adb_command_runner: Function that runs ADB commands
|
||||
algorithm: Hash algorithm to use
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to their hash values
|
||||
"""
|
||||
if not adb_command_runner:
|
||||
self._update_status("No ADB command runner provided")
|
||||
return {}
|
||||
|
||||
|
||||
hash_map = {}
|
||||
total_files = len(file_paths)
|
||||
|
||||
|
||||
for index, file_path in enumerate(file_paths):
|
||||
file_hash = self.compute_remote_file_hash(file_path, algorithm, adb_command_runner)
|
||||
|
||||
file_hash = self.compute_remote_file_hash(
|
||||
file_path, algorithm, adb_command_runner
|
||||
)
|
||||
|
||||
if file_hash:
|
||||
hash_map[file_path] = file_hash
|
||||
|
||||
|
||||
# Update progress
|
||||
if total_files > 0:
|
||||
progress_percentage = int((index + 1) * 100 / total_files)
|
||||
self._update_progress(progress_percentage)
|
||||
self._update_status(f"Computing remote hashes... {index + 1}/{total_files}")
|
||||
|
||||
self._update_status(
|
||||
f"Computing remote hashes... {index + 1}/{total_files}"
|
||||
)
|
||||
|
||||
return hash_map
|
||||
|
||||
def find_duplicate_files(self, source_files: List[str], target_files: List[str],
|
||||
is_remote_source: bool = False, is_remote_target: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = 'sha256') -> Tuple[List[str], List[str]]:
|
||||
def find_duplicate_files(
|
||||
self,
|
||||
source_files: List[str],
|
||||
target_files: List[str],
|
||||
is_remote_source: bool = False,
|
||||
is_remote_target: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
algorithm: str = "sha256",
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Find files that are duplicates between source and target lists.
|
||||
|
||||
|
||||
Args:
|
||||
source_files: List of source file paths
|
||||
target_files: List of target file paths
|
||||
@@ -196,56 +224,68 @@ class FileDeduplicator:
|
||||
is_remote_target: True if target files are on Android device
|
||||
adb_command_runner: Function that runs ADB commands
|
||||
algorithm: Hash algorithm to use
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (files_to_transfer, duplicate_files)
|
||||
"""
|
||||
self._update_status("Building hash maps for duplicate detection...")
|
||||
|
||||
|
||||
# Build hash maps for both source and target
|
||||
if is_remote_source:
|
||||
source_hashes = self.build_remote_file_hash_map(source_files, adb_command_runner, algorithm)
|
||||
source_hashes = self.build_remote_file_hash_map(
|
||||
source_files, adb_command_runner, algorithm
|
||||
)
|
||||
else:
|
||||
source_hashes = self.build_local_file_hash_map(source_files, algorithm)
|
||||
|
||||
|
||||
if is_remote_target:
|
||||
target_hashes = self.build_remote_file_hash_map(target_files, adb_command_runner, algorithm)
|
||||
target_hashes = self.build_remote_file_hash_map(
|
||||
target_files, adb_command_runner, algorithm
|
||||
)
|
||||
else:
|
||||
target_hashes = self.build_local_file_hash_map(target_files, algorithm)
|
||||
|
||||
|
||||
# Find duplicates by comparing hashes
|
||||
target_hash_values = set(target_hashes.values())
|
||||
files_to_transfer = []
|
||||
duplicate_files = []
|
||||
|
||||
|
||||
for source_file in source_files:
|
||||
source_hash = source_hashes.get(source_file)
|
||||
if source_hash and source_hash in target_hash_values:
|
||||
duplicate_files.append(source_file)
|
||||
else:
|
||||
files_to_transfer.append(source_file)
|
||||
|
||||
self._update_status(f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer")
|
||||
|
||||
|
||||
self._update_status(
|
||||
f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer"
|
||||
)
|
||||
|
||||
return files_to_transfer, duplicate_files
|
||||
|
||||
def get_file_size(self, file_path: str, is_remote: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None) -> Optional[int]:
|
||||
def get_file_size(
|
||||
self,
|
||||
file_path: str,
|
||||
is_remote: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
) -> Optional[int]:
|
||||
"""Get the size of a file in bytes.
|
||||
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
is_remote: True if file is on Android device
|
||||
adb_command_runner: Function that runs ADB commands
|
||||
|
||||
|
||||
Returns:
|
||||
File size in bytes, or None if error
|
||||
"""
|
||||
try:
|
||||
if is_remote and adb_command_runner:
|
||||
cmd = ['shell', 'stat', '-c', '%s', file_path]
|
||||
stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True)
|
||||
|
||||
cmd = ["shell", "stat", "-c", "%s", file_path]
|
||||
stdout, stderr, returncode = adb_command_runner(
|
||||
cmd, capture_output=True
|
||||
)
|
||||
|
||||
if returncode == 0 and stdout.strip().isdigit():
|
||||
return int(stdout.strip())
|
||||
else:
|
||||
@@ -258,35 +298,39 @@ class FileDeduplicator:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def calculate_transfer_savings(self, duplicate_files: List[str], is_remote: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None) -> Tuple[int, int]:
|
||||
def calculate_transfer_savings(
|
||||
self,
|
||||
duplicate_files: List[str],
|
||||
is_remote: bool = False,
|
||||
adb_command_runner: Optional[Callable] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate the number of bytes and files that would be saved by skipping duplicates.
|
||||
|
||||
|
||||
Args:
|
||||
duplicate_files: List of duplicate file paths
|
||||
is_remote: True if files are on Android device
|
||||
adb_command_runner: Function that runs ADB commands
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (bytes_saved, files_saved)
|
||||
"""
|
||||
bytes_saved = 0
|
||||
files_saved = 0
|
||||
|
||||
|
||||
for file_path in duplicate_files:
|
||||
file_size = self.get_file_size(file_path, is_remote, adb_command_runner)
|
||||
if file_size is not None:
|
||||
bytes_saved += file_size
|
||||
files_saved += 1
|
||||
|
||||
|
||||
return bytes_saved, files_saved
|
||||
|
||||
def format_bytes(self, bytes_value: int) -> str:
|
||||
"""Format bytes into human-readable string.
|
||||
|
||||
|
||||
Args:
|
||||
bytes_value: Number of bytes
|
||||
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "1.5 MB")
|
||||
"""
|
||||
|
||||
+21
-12
@@ -8,8 +8,8 @@ import re
|
||||
from typing import Optional
|
||||
|
||||
# Pre-compiled regex patterns for performance
|
||||
_DANGEROUS_CHAR_PATTERN = re.compile(r'[;|&$`\n\r><(){}[\]!]')
|
||||
_DANGEROUS_PATH_PATTERN = re.compile(r'[;|&`\n\r]|\$[({]|&&|\|\||>>')
|
||||
_DANGEROUS_CHAR_PATTERN = re.compile(r"[;|&$`\n\r><(){}[\]!]")
|
||||
_DANGEROUS_PATH_PATTERN = re.compile(r"[;|&`\n\r]|\$[({]|&&|\|\||>>")
|
||||
|
||||
|
||||
def sanitize_path_component(component: str) -> str:
|
||||
@@ -28,17 +28,19 @@ def sanitize_path_component(component: str) -> str:
|
||||
raise ValueError("Path component cannot be empty")
|
||||
|
||||
# Check for null bytes
|
||||
if '\x00' in component:
|
||||
if "\x00" in component:
|
||||
raise ValueError("Path component contains null byte")
|
||||
|
||||
# Check for dangerous characters using pre-compiled regex
|
||||
# Matches any shell metacharacters that could be used for command injection
|
||||
match = _DANGEROUS_CHAR_PATTERN.search(component)
|
||||
if match:
|
||||
raise ValueError(f"Path component contains dangerous character: {match.group()}")
|
||||
raise ValueError(
|
||||
f"Path component contains dangerous character: {match.group()}"
|
||||
)
|
||||
|
||||
# Check for command substitution patterns
|
||||
if '$(' in component or '${' in component:
|
||||
if "$(" in component or "${" in component:
|
||||
raise ValueError("Path component contains command substitution pattern")
|
||||
|
||||
return component
|
||||
@@ -63,7 +65,7 @@ def sanitize_android_path(path: str) -> str:
|
||||
path = path.strip()
|
||||
|
||||
# Check for null bytes
|
||||
if '\x00' in path:
|
||||
if "\x00" in path:
|
||||
raise ValueError("Path contains null byte")
|
||||
|
||||
# Check for command injection patterns using pre-compiled regex
|
||||
@@ -83,7 +85,9 @@ def sanitize_android_path(path: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True) -> str:
|
||||
def sanitize_local_path(
|
||||
path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True
|
||||
) -> str:
|
||||
"""Sanitize a local filesystem path and check for path traversal.
|
||||
|
||||
Args:
|
||||
@@ -104,7 +108,7 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis
|
||||
path = path.strip()
|
||||
|
||||
# Check for null bytes
|
||||
if '\x00' in path:
|
||||
if "\x00" in path:
|
||||
raise ValueError("Path contains null byte")
|
||||
|
||||
# Normalize the path to resolve .. and symlinks
|
||||
@@ -131,8 +135,13 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis
|
||||
else:
|
||||
base_dir_abs = os.path.normpath(os.path.realpath(base_dir))
|
||||
# Check if the normalized path starts with the base directory
|
||||
if not normalized_path.startswith(base_dir_abs + os.sep) and normalized_path != base_dir_abs:
|
||||
raise ValueError(f"Path traversal detected: path is outside base directory")
|
||||
if (
|
||||
not normalized_path.startswith(base_dir_abs + os.sep)
|
||||
and normalized_path != base_dir_abs
|
||||
):
|
||||
raise ValueError(
|
||||
f"Path traversal detected: path is outside base directory"
|
||||
)
|
||||
except (ValueError, OSError) as e:
|
||||
raise ValueError(f"Invalid base directory: {e}")
|
||||
|
||||
@@ -155,11 +164,11 @@ def validate_device_id(device_id: str) -> str:
|
||||
raise ValueError("Device ID cannot be empty")
|
||||
|
||||
# Device IDs should only contain alphanumeric characters, dots, colons, and hyphens
|
||||
if not re.match(r'^[a-zA-Z0-9.:_-]+$', device_id):
|
||||
if not re.match(r"^[a-zA-Z0-9.:_-]+$", device_id):
|
||||
raise ValueError("Device ID contains invalid characters")
|
||||
|
||||
# Check for command injection patterns
|
||||
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<']
|
||||
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
|
||||
for char in dangerous_chars:
|
||||
if char in device_id:
|
||||
raise ValueError(f"Device ID contains dangerous character: {char}")
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""Test package initialization."""
|
||||
"""Test package initialization."""
|
||||
|
||||
+13
-12
@@ -5,13 +5,14 @@ import sys
|
||||
import os
|
||||
|
||||
# Add src directory to path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tkinter_root():
|
||||
"""Create a mock Tkinter root window for GUI tests."""
|
||||
import unittest.mock
|
||||
|
||||
return unittest.mock.MagicMock()
|
||||
|
||||
|
||||
@@ -19,20 +20,20 @@ def mock_tkinter_root():
|
||||
def mock_tkinter_imports():
|
||||
"""Mock tkinter imports to avoid GUI dependencies in tests."""
|
||||
import unittest.mock
|
||||
|
||||
|
||||
# Mock tkinter modules
|
||||
mock_tk = unittest.mock.MagicMock()
|
||||
mock_messagebox = unittest.mock.MagicMock()
|
||||
mock_filedialog = unittest.mock.MagicMock()
|
||||
|
||||
|
||||
modules_to_mock = {
|
||||
'tkinter': mock_tk,
|
||||
'tkinter.messagebox': mock_messagebox,
|
||||
'tkinter.filedialog': mock_filedialog,
|
||||
'tkinter.ttk': unittest.mock.MagicMock(),
|
||||
"tkinter": mock_tk,
|
||||
"tkinter.messagebox": mock_messagebox,
|
||||
"tkinter.filedialog": mock_filedialog,
|
||||
"tkinter.ttk": unittest.mock.MagicMock(),
|
||||
}
|
||||
|
||||
with unittest.mock.patch.dict('sys.modules', modules_to_mock):
|
||||
|
||||
with unittest.mock.patch.dict("sys.modules", modules_to_mock):
|
||||
yield
|
||||
|
||||
|
||||
@@ -41,7 +42,7 @@ def temp_directory():
|
||||
"""Create a temporary directory for testing."""
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir)
|
||||
@@ -52,11 +53,11 @@ def temp_file():
|
||||
"""Create a temporary file for testing."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
fd, temp_path = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
yield temp_path
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -10,179 +10,178 @@ from src.core.adb_command import ADBCommandRunner
|
||||
|
||||
class TestADBCommandRunner:
|
||||
"""Test ADB command runner functionality."""
|
||||
|
||||
|
||||
def test_init(self):
|
||||
"""Test ADBCommandRunner initialization."""
|
||||
runner = ADBCommandRunner()
|
||||
assert runner.current_process is None
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.run')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.run")
|
||||
def test_run_adb_command_success(self, mock_subprocess, mock_get_path):
|
||||
"""Test successful ADB command execution."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "device_list"
|
||||
mock_result.stderr = ""
|
||||
mock_subprocess.return_value = mock_result
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
stdout, stderr, returncode = runner.run_adb_command(['devices'])
|
||||
|
||||
stdout, stderr, returncode = runner.run_adb_command(["devices"])
|
||||
|
||||
assert returncode == 0
|
||||
assert stdout == "device_list"
|
||||
assert stderr == ""
|
||||
mock_subprocess.assert_called_once_with(
|
||||
['/path/to/adb', 'devices'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
["/path/to/adb", "devices"], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.run')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.run")
|
||||
def test_run_adb_command_failure(self, mock_subprocess, mock_get_path):
|
||||
"""Test ADB command execution failure."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "error message"
|
||||
mock_subprocess.return_value = mock_result
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
stdout, stderr, returncode = runner.run_adb_command(['invalid'])
|
||||
|
||||
stdout, stderr, returncode = runner.run_adb_command(["invalid"])
|
||||
|
||||
assert returncode == 1
|
||||
assert stdout == ""
|
||||
assert stderr == "error message"
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.run')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.run")
|
||||
def test_run_adb_command_exception(self, mock_subprocess, mock_get_path):
|
||||
"""Test ADB command execution with exception."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_subprocess.side_effect = FileNotFoundError("ADB not found")
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
stdout, stderr, returncode = runner.run_adb_command(['devices'])
|
||||
|
||||
stdout, stderr, returncode = runner.run_adb_command(["devices"])
|
||||
|
||||
assert returncode == -1
|
||||
assert stdout is None
|
||||
assert "ADB not found" in stderr
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.Popen')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.Popen")
|
||||
def test_run_adb_command_no_capture(self, mock_popen, mock_get_path):
|
||||
"""Test ADB command execution without output capture."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.run_adb_command(['devices'], capture_output=False)
|
||||
|
||||
result = runner.run_adb_command(["devices"], capture_output=False)
|
||||
|
||||
assert result == mock_process
|
||||
mock_popen.assert_called_once_with(
|
||||
['/path/to/adb', 'devices'],
|
||||
["/path/to/adb", "devices"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_check_device_no_process(self):
|
||||
"""Test device check with no current process."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.check_device()
|
||||
assert result is None
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.run')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.run")
|
||||
def test_check_device_with_devices(self, mock_subprocess, mock_get_path):
|
||||
"""Test device check with connected devices."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "List of devices attached\ndevice1\tdevice\ndevice2\tdevice"
|
||||
mock_result.stdout = (
|
||||
"List of devices attached\ndevice1\tdevice\ndevice2\tdevice"
|
||||
)
|
||||
mock_subprocess.return_value = mock_result
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.check_device()
|
||||
|
||||
|
||||
assert result == "device1"
|
||||
|
||||
@patch('src.core.adb_command.get_adb_binary_path')
|
||||
@patch('subprocess.run')
|
||||
|
||||
@patch("src.core.adb_command.get_adb_binary_path")
|
||||
@patch("subprocess.run")
|
||||
def test_check_device_no_devices(self, mock_subprocess, mock_get_path):
|
||||
"""Test device check with no connected devices."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "List of devices attached\n"
|
||||
mock_subprocess.return_value = mock_result
|
||||
|
||||
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.check_device()
|
||||
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_progress_valid_percentage(self):
|
||||
"""Test parsing valid progress percentage."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.parse_progress("Transferring: 45%")
|
||||
assert result == 45
|
||||
|
||||
|
||||
def test_parse_progress_valid_fraction(self):
|
||||
"""Test parsing valid progress fraction."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.parse_progress("1024/2048 KB transferred")
|
||||
assert result == 50
|
||||
|
||||
|
||||
def test_parse_progress_valid_bytes(self):
|
||||
"""Test parsing valid progress bytes."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.parse_progress("2048 KB/s (1048576 bytes in 2.5s)")
|
||||
assert result == 100
|
||||
|
||||
|
||||
def test_parse_progress_invalid(self):
|
||||
"""Test parsing invalid progress string."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.parse_progress("No progress info here")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_progress_empty(self):
|
||||
"""Test parsing empty progress string."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.parse_progress("")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_cancel_current_operation_no_process(self):
|
||||
"""Test canceling operation with no current process."""
|
||||
runner = ADBCommandRunner()
|
||||
result = runner.cancel_current_operation()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_cancel_current_operation_with_process(self):
|
||||
"""Test canceling operation with active process."""
|
||||
runner = ADBCommandRunner()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Process is running
|
||||
runner.current_process = mock_process
|
||||
|
||||
|
||||
result = runner.cancel_current_operation()
|
||||
|
||||
|
||||
assert result is True
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
|
||||
def test_cancel_current_operation_finished_process(self):
|
||||
"""Test canceling operation with finished process."""
|
||||
runner = ADBCommandRunner()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = 0 # Process finished
|
||||
runner.current_process = mock_process
|
||||
|
||||
|
||||
result = runner.cancel_current_operation()
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_process.terminate.assert_not_called()
|
||||
mock_process.terminate.assert_not_called()
|
||||
|
||||
+163
-159
@@ -10,160 +10,170 @@ from src.core.adb_manager import ADBManager
|
||||
|
||||
class TestADBManager:
|
||||
"""Test main ADB manager functionality."""
|
||||
|
||||
@patch('src.core.adb_manager.get_adb_binary_path')
|
||||
|
||||
@patch("src.core.adb_manager.get_adb_binary_path")
|
||||
def test_init_success(self, mock_get_path):
|
||||
"""Test ADBManager initialization with successful ADB path."""
|
||||
mock_get_path.return_value = '/path/to/adb'
|
||||
|
||||
mock_get_path.return_value = "/path/to/adb"
|
||||
|
||||
manager = ADBManager()
|
||||
|
||||
assert manager.adb_path == '/path/to/adb'
|
||||
|
||||
assert manager.adb_path == "/path/to/adb"
|
||||
assert manager.selected_device is None
|
||||
assert manager.progress_callback is None
|
||||
assert manager.status_callback is None
|
||||
assert manager.transfer_progress['current_file'] == 0
|
||||
|
||||
@patch('src.core.adb_manager.get_adb_binary_path')
|
||||
assert manager.transfer_progress["current_file"] == 0
|
||||
|
||||
@patch("src.core.adb_manager.get_adb_binary_path")
|
||||
def test_init_adb_path_failure(self, mock_get_path):
|
||||
"""Test ADBManager initialization when ADB path fails."""
|
||||
mock_get_path.side_effect = Exception("ADB not found")
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
|
||||
|
||||
assert manager.adb_path is None
|
||||
|
||||
@patch('src.core.adb_manager.is_adb_available')
|
||||
|
||||
@patch("src.core.adb_manager.is_adb_available")
|
||||
def test_is_available_true(self, mock_is_available):
|
||||
"""Test ADB availability check returns True."""
|
||||
mock_is_available.return_value = True
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
assert manager.is_available() is True
|
||||
|
||||
@patch('src.core.adb_manager.is_adb_available')
|
||||
|
||||
@patch("src.core.adb_manager.is_adb_available")
|
||||
def test_is_available_false(self, mock_is_available):
|
||||
"""Test ADB availability check returns False."""
|
||||
mock_is_available.return_value = False
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
assert manager.is_available() is False
|
||||
|
||||
@patch('src.core.adb_manager.is_adb_available')
|
||||
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
|
||||
@patch('os.path.exists')
|
||||
def test_ensure_adb_installed_already_available(self, mock_exists, mock_ensure, mock_is_available):
|
||||
|
||||
@patch("src.core.adb_manager.is_adb_available")
|
||||
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
|
||||
@patch("os.path.exists")
|
||||
def test_ensure_adb_installed_already_available(
|
||||
self, mock_exists, mock_ensure, mock_is_available
|
||||
):
|
||||
"""Test ensure_adb_installed when ADB is already available."""
|
||||
mock_is_available.return_value = True
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
result = manager.ensure_adb_installed()
|
||||
|
||||
|
||||
assert result is True
|
||||
mock_ensure.assert_not_called()
|
||||
|
||||
@patch('src.core.adb_manager.is_adb_available')
|
||||
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
|
||||
@patch('os.path.exists')
|
||||
def test_ensure_adb_installed_download_success(self, mock_exists, mock_ensure, mock_is_available):
|
||||
|
||||
@patch("src.core.adb_manager.is_adb_available")
|
||||
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
|
||||
@patch("os.path.exists")
|
||||
def test_ensure_adb_installed_download_success(
|
||||
self, mock_exists, mock_ensure, mock_is_available
|
||||
):
|
||||
"""Test ensure_adb_installed with successful download."""
|
||||
mock_is_available.return_value = False
|
||||
mock_ensure.return_value = '/user/data/adb'
|
||||
mock_ensure.return_value = "/user/data/adb"
|
||||
mock_exists.return_value = True
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
result = manager.ensure_adb_installed()
|
||||
|
||||
|
||||
assert result is True
|
||||
assert manager.adb_path == '/user/data/adb'
|
||||
|
||||
@patch('src.core.adb_manager.is_adb_available')
|
||||
@patch('src.core.adb_manager.ensure_platform_tools_in_user_dir')
|
||||
def test_ensure_adb_installed_download_failure(self, mock_ensure, mock_is_available):
|
||||
assert manager.adb_path == "/user/data/adb"
|
||||
|
||||
@patch("src.core.adb_manager.is_adb_available")
|
||||
@patch("src.core.adb_manager.ensure_platform_tools_in_user_dir")
|
||||
def test_ensure_adb_installed_download_failure(
|
||||
self, mock_ensure, mock_is_available
|
||||
):
|
||||
"""Test ensure_adb_installed with download failure."""
|
||||
mock_is_available.return_value = False
|
||||
mock_ensure.side_effect = Exception("Download failed")
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
result = manager.ensure_adb_installed()
|
||||
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_get_devices_success(self):
|
||||
"""Test successful device enumeration."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("List of devices attached\ndevice1\tdevice\ndevice2\tdevice", "", 0)
|
||||
return_value=(
|
||||
"List of devices attached\ndevice1\tdevice\ndevice2\tdevice",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
devices = manager.get_devices()
|
||||
|
||||
|
||||
assert devices == ["device1", "device2"]
|
||||
|
||||
|
||||
def test_get_devices_failure(self):
|
||||
"""Test device enumeration failure."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "Error", 1)
|
||||
)
|
||||
|
||||
|
||||
devices = manager.get_devices()
|
||||
|
||||
|
||||
assert devices == []
|
||||
|
||||
|
||||
def test_get_devices_no_devices(self):
|
||||
"""Test device enumeration with no connected devices."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("List of devices attached\n", "", 0)
|
||||
)
|
||||
|
||||
|
||||
devices = manager.get_devices()
|
||||
|
||||
|
||||
assert devices == []
|
||||
|
||||
@patch.object(ADBManager, 'get_devices')
|
||||
|
||||
@patch.object(ADBManager, "get_devices")
|
||||
def test_is_device_connected_specific_device(self, mock_get_devices):
|
||||
"""Test checking if specific device is connected."""
|
||||
mock_get_devices.return_value = ["device1", "device2"]
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
|
||||
|
||||
assert manager.is_device_connected("device1") is True
|
||||
assert manager.is_device_connected("device3") is False
|
||||
|
||||
@patch.object(ADBManager, 'get_devices')
|
||||
|
||||
@patch.object(ADBManager, "get_devices")
|
||||
def test_is_device_connected_any_device(self, mock_get_devices):
|
||||
"""Test checking if any device is connected."""
|
||||
mock_get_devices.return_value = ["device1"]
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
|
||||
|
||||
assert manager.is_device_connected() is True
|
||||
|
||||
|
||||
mock_get_devices.return_value = []
|
||||
assert manager.is_device_connected() is False
|
||||
|
||||
|
||||
def test_select_device(self):
|
||||
"""Test device selection."""
|
||||
manager = ADBManager()
|
||||
manager.select_device("test_device")
|
||||
|
||||
|
||||
assert manager.selected_device == "test_device"
|
||||
|
||||
|
||||
def test_get_selected_device(self):
|
||||
"""Test getting selected device."""
|
||||
manager = ADBManager()
|
||||
manager.selected_device = "test_device"
|
||||
|
||||
|
||||
assert manager.get_selected_device() == "test_device"
|
||||
|
||||
|
||||
def test_get_selected_device_none(self):
|
||||
"""Test getting selected device when none selected."""
|
||||
manager = ADBManager()
|
||||
|
||||
|
||||
assert manager.get_selected_device() is None
|
||||
|
||||
|
||||
def test_list_files_success(self):
|
||||
"""Test successful file listing."""
|
||||
manager = ADBManager()
|
||||
@@ -172,233 +182,223 @@ class TestADBManager:
|
||||
"total 12\n"
|
||||
"drwxrwxr-x 2 user user 4096 Jan 1 12:00 Documents\n"
|
||||
"-rw-rw-r-- 1 user user 100 Jan 1 12:00 test.txt",
|
||||
"", 0
|
||||
"",
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
files = manager.list_files("/sdcard")
|
||||
|
||||
|
||||
assert len(files) == 2
|
||||
assert files[0]['name'] == 'Documents'
|
||||
assert files[0]['type'] == 'folder'
|
||||
assert files[1]['name'] == 'test.txt'
|
||||
assert files[1]['type'] == 'file'
|
||||
assert files[1]['size'] == 100
|
||||
|
||||
assert files[0]["name"] == "Documents"
|
||||
assert files[0]["type"] == "folder"
|
||||
assert files[1]["name"] == "test.txt"
|
||||
assert files[1]["type"] == "file"
|
||||
assert files[1]["size"] == 100
|
||||
|
||||
def test_list_files_failure(self):
|
||||
"""Test file listing failure."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "Permission denied", 1)
|
||||
)
|
||||
|
||||
|
||||
files = manager.list_files("/root")
|
||||
|
||||
|
||||
assert files == []
|
||||
|
||||
@patch('os.makedirs')
|
||||
|
||||
@patch("os.makedirs")
|
||||
def test_pull_file_success(self, mock_makedirs):
|
||||
"""Test successful file pull."""
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.pull_file = MagicMock(return_value=True)
|
||||
|
||||
|
||||
success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "successfully" in message
|
||||
mock_makedirs.assert_called_once_with("/local", exist_ok=True)
|
||||
|
||||
|
||||
def test_pull_file_failure(self):
|
||||
"""Test file pull failure."""
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.pull_file = MagicMock(return_value=False)
|
||||
|
||||
|
||||
success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt")
|
||||
|
||||
|
||||
assert success is False
|
||||
assert "Failed" in message
|
||||
|
||||
@patch('os.makedirs')
|
||||
|
||||
@patch("os.makedirs")
|
||||
def test_pull_folder_success(self, mock_makedirs):
|
||||
"""Test successful folder pull."""
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.pull_folder = MagicMock(return_value=True)
|
||||
|
||||
|
||||
success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "successfully" in message
|
||||
mock_makedirs.assert_called_once_with("/local/Documents", exist_ok=True)
|
||||
|
||||
|
||||
def test_pull_folder_failure(self):
|
||||
"""Test folder pull failure."""
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.pull_folder = MagicMock(return_value=False)
|
||||
|
||||
|
||||
success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents")
|
||||
|
||||
|
||||
assert success is False
|
||||
assert "Failed" in message
|
||||
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("os.path.exists")
|
||||
def test_push_file_success(self, mock_exists):
|
||||
"""Test successful file push."""
|
||||
mock_exists.return_value = True
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.push_file = MagicMock(return_value=True)
|
||||
|
||||
|
||||
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "successfully" in message
|
||||
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("os.path.exists")
|
||||
def test_push_file_not_found(self, mock_exists):
|
||||
"""Test file push when local file doesn't exist."""
|
||||
mock_exists.return_value = False
|
||||
manager = ADBManager()
|
||||
|
||||
|
||||
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
|
||||
|
||||
|
||||
assert success is False
|
||||
assert "not found" in message
|
||||
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("os.path.exists")
|
||||
def test_push_file_failure(self, mock_exists):
|
||||
"""Test file push failure."""
|
||||
mock_exists.return_value = True
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.push_file = MagicMock(return_value=False)
|
||||
|
||||
|
||||
success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt")
|
||||
|
||||
|
||||
assert success is False
|
||||
assert "Failed" in message
|
||||
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("os.path.exists")
|
||||
def test_push_folder_success(self, mock_exists):
|
||||
"""Test successful folder push."""
|
||||
mock_exists.return_value = True
|
||||
manager = ADBManager()
|
||||
manager.file_transfer.push_folder = MagicMock(return_value=True)
|
||||
|
||||
|
||||
success, message = manager.push_folder("/local/Documents", "/sdcard/Documents")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "successfully" in message
|
||||
|
||||
|
||||
def test_delete_file_success(self):
|
||||
"""Test successful file deletion."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "", 0)
|
||||
)
|
||||
|
||||
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
|
||||
|
||||
success, message = manager.delete_file("/sdcard/test.txt")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "deleted" in message
|
||||
|
||||
|
||||
def test_delete_file_failure(self):
|
||||
"""Test file deletion failure."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "Permission denied", 1)
|
||||
)
|
||||
|
||||
|
||||
success, message = manager.delete_file("/sdcard/test.txt")
|
||||
|
||||
|
||||
assert success is False
|
||||
assert "Failed" in message
|
||||
|
||||
|
||||
def test_create_folder_success(self):
|
||||
"""Test successful folder creation."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "", 0)
|
||||
)
|
||||
|
||||
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
|
||||
|
||||
success, message = manager.create_folder("/sdcard/NewFolder")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "created" in message
|
||||
|
||||
|
||||
def test_delete_folder_success(self):
|
||||
"""Test successful folder deletion."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "", 0)
|
||||
)
|
||||
|
||||
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
|
||||
|
||||
success, message = manager.delete_folder("/sdcard/OldFolder")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "deleted" in message
|
||||
|
||||
|
||||
def test_move_item_success(self):
|
||||
"""Test successful item move/rename."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "", 0)
|
||||
)
|
||||
|
||||
manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0))
|
||||
|
||||
success, message = manager.move_item("/sdcard/old.txt", "/sdcard/new.txt")
|
||||
|
||||
|
||||
assert success is True
|
||||
assert "moved" in message
|
||||
|
||||
|
||||
def test_get_file_info_success(self):
|
||||
"""Test successful file info retrieval."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=(
|
||||
"-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt",
|
||||
"", 0
|
||||
)
|
||||
return_value=("-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt", "", 0)
|
||||
)
|
||||
|
||||
|
||||
info = manager.get_file_info("/sdcard/test.txt")
|
||||
|
||||
|
||||
assert info is not None
|
||||
assert info['name'] == 'test.txt'
|
||||
assert info['type'] == 'file'
|
||||
assert info['size'] == 1024
|
||||
|
||||
assert info["name"] == "test.txt"
|
||||
assert info["type"] == "file"
|
||||
assert info["size"] == 1024
|
||||
|
||||
def test_get_file_info_failure(self):
|
||||
"""Test file info retrieval failure."""
|
||||
manager = ADBManager()
|
||||
manager.command_runner.run_adb_command = MagicMock(
|
||||
return_value=("", "File not found", 1)
|
||||
)
|
||||
|
||||
|
||||
info = manager.get_file_info("/sdcard/nonexistent.txt")
|
||||
|
||||
|
||||
assert info is None
|
||||
|
||||
@patch('src.core.adb_manager.FileDeduplicator')
|
||||
|
||||
@patch("src.core.adb_manager.FileDeduplicator")
|
||||
def test_deduplicate_files_success(self, mock_deduplicator_class):
|
||||
"""Test successful file deduplication."""
|
||||
mock_deduplicator = MagicMock()
|
||||
mock_deduplicator.find_duplicates.return_value = [['file1.txt', 'file2.txt']]
|
||||
mock_deduplicator.find_duplicates.return_value = [["file1.txt", "file2.txt"]]
|
||||
mock_deduplicator.remove_duplicates.return_value = 1
|
||||
mock_deduplicator_class.return_value = mock_deduplicator
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
removed_count, duplicates = manager.deduplicate_files("/test/folder")
|
||||
|
||||
|
||||
assert removed_count == 1
|
||||
assert len(duplicates) == 1
|
||||
|
||||
@patch('src.core.adb_manager.FileDeduplicator')
|
||||
|
||||
@patch("src.core.adb_manager.FileDeduplicator")
|
||||
def test_deduplicate_files_no_duplicates(self, mock_deduplicator_class):
|
||||
"""Test file deduplication with no duplicates found."""
|
||||
mock_deduplicator = MagicMock()
|
||||
mock_deduplicator.find_duplicates.return_value = []
|
||||
mock_deduplicator_class.return_value = mock_deduplicator
|
||||
|
||||
|
||||
manager = ADBManager()
|
||||
removed_count, duplicates = manager.deduplicate_files("/test/folder")
|
||||
|
||||
|
||||
assert removed_count == 0
|
||||
assert duplicates == []
|
||||
|
||||
@@ -436,7 +436,9 @@ class TestADBManagerSecurityIntegration:
|
||||
for path in malicious_paths:
|
||||
success, message = manager.delete_file(path)
|
||||
assert not success, f"Failed to reject malicious path: {path}"
|
||||
assert "Invalid path" in message, f"Expected security error message for: {path}"
|
||||
assert (
|
||||
"Invalid path" in message
|
||||
), f"Expected security error message for: {path}"
|
||||
|
||||
def test_create_folder_rejects_malicious_input(self):
|
||||
"""Test that create_folder() rejects malicious path inputs."""
|
||||
@@ -465,7 +467,9 @@ class TestADBManagerSecurityIntegration:
|
||||
assert "Invalid path" in message
|
||||
|
||||
# Malicious destination
|
||||
success, message = manager.move_item("/sdcard/source", "/sdcard/dest && malicious")
|
||||
success, message = manager.move_item(
|
||||
"/sdcard/source", "/sdcard/dest && malicious"
|
||||
)
|
||||
assert not success
|
||||
assert "Invalid path" in message
|
||||
|
||||
@@ -513,7 +517,7 @@ class TestADBManagerSecurityIntegration:
|
||||
result = manager.get_file_info("/sdcard/file\x00.txt")
|
||||
assert result is None
|
||||
|
||||
@patch('src.core.adb_manager.ADBCommandRunner')
|
||||
@patch("src.core.adb_manager.ADBCommandRunner")
|
||||
def test_sanitized_paths_passed_to_adb_commands(self, mock_runner_class):
|
||||
"""Test that sanitized paths are passed to ADB commands, not original inputs."""
|
||||
mock_runner = MagicMock()
|
||||
@@ -616,4 +620,4 @@ class TestADBManagerSecurityIntegration:
|
||||
result = manager.list_files("/sdcard/DCIM")
|
||||
|
||||
# No status messages for validation errors
|
||||
assert not any("Invalid" in msg for msg in status_messages)
|
||||
assert not any("Invalid" in msg for msg in status_messages)
|
||||
|
||||
@@ -10,33 +10,33 @@ from src.core.file_transfer import ADBFileTransfer
|
||||
|
||||
class TestADBFileTransfer:
|
||||
"""Test ADB file transfer functionality."""
|
||||
|
||||
|
||||
def test_init(self):
|
||||
"""Test ADBFileTransfer initialization."""
|
||||
transfer = ADBFileTransfer()
|
||||
assert transfer.progress_callback is None
|
||||
assert transfer.current_process is None
|
||||
|
||||
|
||||
def test_validate_windows_root_path_valid(self):
|
||||
"""Test Windows root path validation with valid path."""
|
||||
transfer = ADBFileTransfer()
|
||||
# Should not raise exception
|
||||
transfer._validate_windows_root_path("C:/Users/test", "push")
|
||||
|
||||
|
||||
def test_validate_windows_root_path_invalid_push(self):
|
||||
"""Test Windows root path validation with invalid path for push."""
|
||||
transfer = ADBFileTransfer()
|
||||
with pytest.raises(ValueError, match="Cannot push to Windows root"):
|
||||
transfer._validate_windows_root_path("C:", "push")
|
||||
|
||||
|
||||
def test_validate_windows_root_path_invalid_pull(self):
|
||||
"""Test Windows root path validation with invalid path for pull."""
|
||||
transfer = ADBFileTransfer()
|
||||
with pytest.raises(ValueError, match="Cannot pull from Windows root"):
|
||||
transfer._validate_windows_root_path("C:", "pull")
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_pull_file_success(self, mock_exists, mock_command_runner):
|
||||
"""Test successful file pull operation."""
|
||||
mock_exists.return_value = False # Remote file doesn't exist locally
|
||||
@@ -49,40 +49,42 @@ class TestADBFileTransfer:
|
||||
|
||||
assert result is True
|
||||
# sanitize_local_path converts to absolute path, so we need to match that
|
||||
expected_local_path = os.path.abspath(os.path.normpath('/local/test.txt'))
|
||||
mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/test.txt', expected_local_path])
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt"))
|
||||
mock_runner.run_adb_command.assert_called_with(
|
||||
["pull", "/sdcard/test.txt", expected_local_path]
|
||||
)
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_pull_file_already_exists(self, mock_exists, mock_command_runner):
|
||||
"""Test file pull when local file already exists."""
|
||||
mock_exists.return_value = True
|
||||
mock_runner = MagicMock()
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt")
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_runner.run_adb_command.assert_not_called()
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_pull_file_command_failure(self, mock_exists, mock_command_runner):
|
||||
"""Test file pull with ADB command failure."""
|
||||
mock_exists.return_value = False
|
||||
mock_runner = MagicMock()
|
||||
mock_runner.run_adb_command.return_value = ("", "Error", 1)
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt")
|
||||
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
@patch('os.path.isfile')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.path.isfile")
|
||||
def test_push_file_success(self, mock_isfile, mock_exists, mock_command_runner):
|
||||
"""Test successful file push operation."""
|
||||
mock_exists.return_value = True # Local file exists
|
||||
@@ -96,42 +98,44 @@ class TestADBFileTransfer:
|
||||
|
||||
assert result is True
|
||||
# sanitize_local_path converts to absolute path
|
||||
expected_local_path = os.path.abspath(os.path.normpath('/local/test.txt'))
|
||||
mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/test.txt'])
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt"))
|
||||
mock_runner.run_adb_command.assert_called_with(
|
||||
["push", expected_local_path, "/sdcard/test.txt"]
|
||||
)
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_push_file_not_exists(self, mock_exists, mock_command_runner):
|
||||
"""Test file push when local file doesn't exist."""
|
||||
mock_exists.return_value = False
|
||||
mock_runner = MagicMock()
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.push_file("/local/test.txt", "/sdcard/test.txt")
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_runner.run_adb_command.assert_not_called()
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
@patch('os.path.isfile')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.path.isfile")
|
||||
def test_push_file_not_a_file(self, mock_isfile, mock_exists, mock_command_runner):
|
||||
"""Test file push when local path is not a file."""
|
||||
mock_exists.return_value = True
|
||||
mock_isfile.return_value = False # It's a directory, not a file
|
||||
mock_runner = MagicMock()
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.push_file("/local/test", "/sdcard/test.txt")
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_runner.run_adb_command.assert_not_called()
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
@patch('os.path.isdir')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.path.isdir")
|
||||
def test_pull_folder_success(self, mock_isdir, mock_exists, mock_command_runner):
|
||||
"""Test successful folder pull operation."""
|
||||
mock_exists.return_value = False # Local folder doesn't exist
|
||||
@@ -145,26 +149,28 @@ class TestADBFileTransfer:
|
||||
|
||||
assert result is True
|
||||
# sanitize_local_path converts to absolute path
|
||||
expected_local_path = os.path.abspath(os.path.normpath('/local/Documents'))
|
||||
mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/Documents', expected_local_path])
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
expected_local_path = os.path.abspath(os.path.normpath("/local/Documents"))
|
||||
mock_runner.run_adb_command.assert_called_with(
|
||||
["pull", "/sdcard/Documents", expected_local_path]
|
||||
)
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_pull_folder_already_exists(self, mock_exists, mock_command_runner):
|
||||
"""Test folder pull when local folder already exists."""
|
||||
mock_exists.return_value = True
|
||||
mock_runner = MagicMock()
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.pull_folder("/sdcard/Documents", "/local/Documents")
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_runner.run_adb_command.assert_not_called()
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
@patch('os.path.isdir')
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.path.isdir")
|
||||
def test_push_folder_success(self, mock_isdir, mock_exists, mock_command_runner):
|
||||
"""Test successful folder push operation."""
|
||||
mock_exists.return_value = True # Local folder exists
|
||||
@@ -178,49 +184,51 @@ class TestADBFileTransfer:
|
||||
|
||||
assert result is True
|
||||
# sanitize_local_path converts to absolute path
|
||||
expected_local_path = os.path.abspath(os.path.normpath('/local/Documents'))
|
||||
mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/Documents'])
|
||||
|
||||
@patch('src.core.file_transfer.ADBCommandRunner')
|
||||
@patch('os.path.exists')
|
||||
expected_local_path = os.path.abspath(os.path.normpath("/local/Documents"))
|
||||
mock_runner.run_adb_command.assert_called_with(
|
||||
["push", expected_local_path, "/sdcard/Documents"]
|
||||
)
|
||||
|
||||
@patch("src.core.file_transfer.ADBCommandRunner")
|
||||
@patch("os.path.exists")
|
||||
def test_push_folder_not_exists(self, mock_exists, mock_command_runner):
|
||||
"""Test folder push when local folder doesn't exist."""
|
||||
mock_exists.return_value = False
|
||||
mock_runner = MagicMock()
|
||||
mock_command_runner.return_value = mock_runner
|
||||
|
||||
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.push_folder("/local/Documents", "/sdcard/Documents")
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_runner.run_adb_command.assert_not_called()
|
||||
|
||||
|
||||
def test_cancel_transfer_no_process(self):
|
||||
"""Test canceling transfer with no current process."""
|
||||
transfer = ADBFileTransfer()
|
||||
result = transfer.cancel_transfer()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_cancel_transfer_with_process(self):
|
||||
"""Test canceling transfer with active process."""
|
||||
transfer = ADBFileTransfer()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # Process is running
|
||||
transfer.current_process = mock_process
|
||||
|
||||
|
||||
result = transfer.cancel_transfer()
|
||||
|
||||
|
||||
assert result is True
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
|
||||
def test_cancel_transfer_finished_process(self):
|
||||
"""Test canceling transfer with finished process."""
|
||||
transfer = ADBFileTransfer()
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = 0 # Process finished
|
||||
transfer.current_process = mock_process
|
||||
|
||||
|
||||
result = transfer.cancel_transfer()
|
||||
|
||||
|
||||
assert result is False
|
||||
mock_process.terminate.assert_not_called()
|
||||
mock_process.terminate.assert_not_called()
|
||||
|
||||
@@ -8,10 +8,10 @@ from unittest.mock import Mock, patch, mock_open, MagicMock
|
||||
import pytest
|
||||
|
||||
from src.core.platform_tools import (
|
||||
is_adb_available,
|
||||
is_adb_available,
|
||||
get_adb_binary_path,
|
||||
ensure_platform_tools_in_user_dir,
|
||||
download_and_extract_adb
|
||||
download_and_extract_adb,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,112 +20,159 @@ class TestPlatformTools(unittest.TestCase):
|
||||
|
||||
def test_is_adb_available_true(self):
|
||||
"""Test adb availability when binary exists."""
|
||||
with patch('src.core.platform_tools.get_adb_binary_path', return_value='/path/to/adb'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch(
|
||||
"src.core.platform_tools.get_adb_binary_path", return_value="/path/to/adb"
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
result = is_adb_available()
|
||||
assert result is True
|
||||
|
||||
def test_is_adb_available_false(self):
|
||||
"""Test adb availability when binary doesn't exist."""
|
||||
with patch('src.core.platform_tools.get_adb_binary_path', return_value=None):
|
||||
with patch("src.core.platform_tools.get_adb_binary_path", return_value=None):
|
||||
result = is_adb_available()
|
||||
assert result is False
|
||||
|
||||
def test_get_adb_binary_path_user_dir_success(self):
|
||||
"""Test getting ADB path from user directory."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
return_value="/user/adb",
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
result = get_adb_binary_path()
|
||||
assert result == '/user/adb'
|
||||
assert result == "/user/adb"
|
||||
|
||||
def test_get_adb_binary_path_local_fallback(self):
|
||||
"""Test fallback to local platform-tools."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()):
|
||||
with patch('src.core.platform_utils.get_platform_tools_directory', return_value='/local/platform-tools'):
|
||||
with patch('src.core.platform_utils.get_adb_binary_name', return_value='adb'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch('os.path.join', return_value='/local/platform-tools/adb'):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
side_effect=Exception(),
|
||||
):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_platform_tools_directory",
|
||||
return_value="/local/platform-tools",
|
||||
):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_adb_binary_name", return_value="adb"
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch(
|
||||
"os.path.join", return_value="/local/platform-tools/adb"
|
||||
):
|
||||
result = get_adb_binary_path()
|
||||
assert result == '/local/platform-tools/adb'
|
||||
assert result == "/local/platform-tools/adb"
|
||||
|
||||
def test_get_adb_binary_path_windows(self):
|
||||
"""Test getting ADB path on Windows."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb.exe'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
return_value="/user/adb.exe",
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
result = get_adb_binary_path()
|
||||
assert result == '/user/adb.exe'
|
||||
assert result == "/user/adb.exe"
|
||||
|
||||
def test_ensure_platform_tools_simple(self):
|
||||
"""Test basic platform tools installation."""
|
||||
|
||||
# Just test the function doesn't crash with basic mocking
|
||||
def mock_isdir(path):
|
||||
# Return True for the extracted platform-tools directory and target directory checks
|
||||
if path == '/tmp/test/platform-tools':
|
||||
if path == "/tmp/test/platform-tools":
|
||||
return True
|
||||
if path.endswith('platform-tools') and 'tmp' in path:
|
||||
if path.endswith("platform-tools") and "tmp" in path:
|
||||
return True
|
||||
# Return False for other directory checks (like existing target_dir)
|
||||
return False
|
||||
|
||||
with patch('os.makedirs'):
|
||||
with patch('os.path.islink', return_value=False):
|
||||
with patch('os.path.isdir', side_effect=mock_isdir):
|
||||
with patch('tempfile.mkdtemp', return_value='/tmp/test'):
|
||||
with patch('os.listdir', return_value=['platform-tools']): # Mock directory listing
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch('builtins.open', mock_open()):
|
||||
with patch('zipfile.is_zipfile', return_value=True):
|
||||
with patch('zipfile.ZipFile') as mock_zip:
|
||||
|
||||
with patch("os.makedirs"):
|
||||
with patch("os.path.islink", return_value=False):
|
||||
with patch("os.path.isdir", side_effect=mock_isdir):
|
||||
with patch("tempfile.mkdtemp", return_value="/tmp/test"):
|
||||
with patch(
|
||||
"os.listdir", return_value=["platform-tools"]
|
||||
): # Mock directory listing
|
||||
with patch("requests.get") as mock_get:
|
||||
with patch("builtins.open", mock_open()):
|
||||
with patch("zipfile.is_zipfile", return_value=True):
|
||||
with patch("zipfile.ZipFile") as mock_zip:
|
||||
# Mock zip file entries
|
||||
mock_info = Mock()
|
||||
mock_info.filename = 'platform-tools/adb'
|
||||
mock_info.filename = "platform-tools/adb"
|
||||
mock_info.file_size = 1000
|
||||
mock_zip_instance = MagicMock()
|
||||
mock_zip_instance.infolist.return_value = [mock_info]
|
||||
mock_zip.return_value.__enter__.return_value = mock_zip_instance
|
||||
mock_zip_instance.infolist.return_value = [
|
||||
mock_info
|
||||
]
|
||||
mock_zip.return_value.__enter__.return_value = (
|
||||
mock_zip_instance
|
||||
)
|
||||
|
||||
with patch('shutil.move'):
|
||||
with patch('os.chmod'):
|
||||
with patch('os.symlink'):
|
||||
with patch('shutil.rmtree'):
|
||||
with patch("shutil.move"):
|
||||
with patch("os.chmod"):
|
||||
with patch("os.symlink"):
|
||||
with patch("shutil.rmtree"):
|
||||
mock_response = Mock()
|
||||
mock_response.iter_content.return_value = [b'content']
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.iter_content.return_value = [
|
||||
b"content"
|
||||
]
|
||||
mock_response.raise_for_status.return_value = (
|
||||
None
|
||||
)
|
||||
mock_response.url = "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
|
||||
mock_response.headers = {'Content-Type': 'application/zip'}
|
||||
mock_get.return_value = mock_response
|
||||
mock_response.headers = {
|
||||
"Content-Type": "application/zip"
|
||||
}
|
||||
mock_get.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
result = ensure_platform_tools_in_user_dir()
|
||||
result = (
|
||||
ensure_platform_tools_in_user_dir()
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
def test_download_and_extract_adb_linux(self):
|
||||
"""Test ADB download and extraction on Linux."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch('os.chmod') as mock_chmod:
|
||||
with patch('os.name', 'posix'):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
return_value="/test/adb",
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch("os.chmod") as mock_chmod:
|
||||
with patch("os.name", "posix"):
|
||||
result = download_and_extract_adb()
|
||||
assert result is True
|
||||
mock_chmod.assert_called_once_with('/test/adb', 0o755)
|
||||
mock_chmod.assert_called_once_with("/test/adb", 0o755)
|
||||
|
||||
def test_download_and_extract_adb_windows(self):
|
||||
"""Test ADB download and extraction on Windows."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb.exe'):
|
||||
with patch('os.path.isfile', return_value=True):
|
||||
with patch('os.name', 'nt'):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
return_value="/test/adb.exe",
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch("os.name", "nt"):
|
||||
result = download_and_extract_adb()
|
||||
assert result is True
|
||||
|
||||
def test_download_and_extract_adb_failure(self):
|
||||
"""Test ADB download failure when file doesn't exist."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'):
|
||||
with patch('os.path.isfile', return_value=False):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
return_value="/test/adb",
|
||||
):
|
||||
with patch("os.path.isfile", return_value=False):
|
||||
result = download_and_extract_adb()
|
||||
assert result is False
|
||||
|
||||
def test_download_and_extract_adb_exception(self):
|
||||
"""Test ADB download failure with exception."""
|
||||
with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()):
|
||||
with patch(
|
||||
"src.core.platform_tools.ensure_platform_tools_in_user_dir",
|
||||
side_effect=Exception(),
|
||||
):
|
||||
result = download_and_extract_adb()
|
||||
assert result is False
|
||||
|
||||
@@ -154,5 +201,5 @@ class TestPlatformToolsSecurityValidation:
|
||||
assert True # Documentation test
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,88 +9,103 @@ from unittest.mock import patch
|
||||
from src.core.platform_utils import (
|
||||
get_executable_directory,
|
||||
get_platform_tools_directory,
|
||||
get_platform_type
|
||||
get_platform_type,
|
||||
)
|
||||
|
||||
|
||||
class TestPlatformUtils:
|
||||
"""Test platform utility functions."""
|
||||
|
||||
|
||||
def test_get_executable_directory_frozen(self):
|
||||
"""Test get_executable_directory when running as executable."""
|
||||
with patch.object(sys, 'frozen', True, create=True):
|
||||
with patch.object(sys, 'executable', '/path/to/app'):
|
||||
with patch.object(sys, "frozen", True, create=True):
|
||||
with patch.object(sys, "executable", "/path/to/app"):
|
||||
result = get_executable_directory()
|
||||
assert result == '/path/to'
|
||||
|
||||
assert result == "/path/to"
|
||||
|
||||
def test_get_executable_directory_script(self):
|
||||
"""Test get_executable_directory when running as script."""
|
||||
with patch.object(sys, 'frozen', False, create=True):
|
||||
with patch('os.path.abspath') as mock_abspath:
|
||||
with patch('os.path.dirname') as mock_dirname:
|
||||
mock_abspath.return_value = '/path/to/script.py'
|
||||
mock_dirname.return_value = '/path/to'
|
||||
with patch.object(sys, "frozen", False, create=True):
|
||||
with patch("os.path.abspath") as mock_abspath:
|
||||
with patch("os.path.dirname") as mock_dirname:
|
||||
mock_abspath.return_value = "/path/to/script.py"
|
||||
mock_dirname.return_value = "/path/to"
|
||||
result = get_executable_directory()
|
||||
assert result == '/path/to'
|
||||
assert result == "/path/to"
|
||||
# The function calls abspath on the actual platform_utils.py file
|
||||
mock_abspath.assert_called_once()
|
||||
|
||||
|
||||
def test_get_platform_type_linux(self):
|
||||
"""Test platform type detection for Linux."""
|
||||
with patch('sys.platform', 'linux'):
|
||||
with patch("sys.platform", "linux"):
|
||||
result = get_platform_type()
|
||||
assert result == 'linux'
|
||||
|
||||
assert result == "linux"
|
||||
|
||||
def test_get_platform_type_windows(self):
|
||||
"""Test platform type detection for Windows."""
|
||||
with patch('sys.platform', 'win32'):
|
||||
with patch("sys.platform", "win32"):
|
||||
result = get_platform_type()
|
||||
assert result == 'win32'
|
||||
|
||||
assert result == "win32"
|
||||
|
||||
def test_get_platform_type_darwin(self):
|
||||
"""Test platform type detection for macOS."""
|
||||
with patch('sys.platform', 'darwin'):
|
||||
with patch("sys.platform", "darwin"):
|
||||
result = get_platform_type()
|
||||
assert result == 'darwin'
|
||||
|
||||
assert result == "darwin"
|
||||
|
||||
def test_get_platform_tools_directory_frozen(self):
|
||||
"""Test platform-tools directory when running as executable."""
|
||||
with patch.object(sys, 'frozen', True, create=True):
|
||||
with patch('src.core.platform_utils.get_executable_directory', return_value='/app/dir'):
|
||||
with patch.object(sys, "frozen", True, create=True):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_executable_directory",
|
||||
return_value="/app/dir",
|
||||
):
|
||||
result = get_platform_tools_directory()
|
||||
expected = os.path.join('/app/dir', 'platform-tools')
|
||||
expected = os.path.join("/app/dir", "platform-tools")
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_platform_tools_directory_src(self):
|
||||
"""Test platform-tools directory when running from src."""
|
||||
with patch.object(sys, 'frozen', False, create=True):
|
||||
with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src'):
|
||||
with patch.object(sys, "frozen", False, create=True):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_executable_directory",
|
||||
return_value="/project/src",
|
||||
):
|
||||
result = get_platform_tools_directory()
|
||||
expected = os.path.join('/project/src', 'platform-tools')
|
||||
expected = os.path.join("/project/src", "platform-tools")
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_platform_tools_directory_gui_subdirectory(self):
|
||||
"""Test platform-tools directory when running from src/gui."""
|
||||
with patch.object(sys, 'frozen', False, create=True):
|
||||
with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src/gui'):
|
||||
with patch.object(sys, "frozen", False, create=True):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_executable_directory",
|
||||
return_value="/project/src/gui",
|
||||
):
|
||||
result = get_platform_tools_directory()
|
||||
expected = os.path.join('/project/src', 'platform-tools')
|
||||
expected = os.path.join("/project/src", "platform-tools")
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_platform_tools_directory_project_root(self):
|
||||
"""Test platform-tools directory when running from project root."""
|
||||
with patch.object(sys, 'frozen', False, create=True):
|
||||
with patch('src.core.platform_utils.get_executable_directory', return_value='/project'):
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch.object(sys, "frozen", False, create=True):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_executable_directory",
|
||||
return_value="/project",
|
||||
):
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = get_platform_tools_directory()
|
||||
expected = os.path.join('/project', 'src', 'platform-tools')
|
||||
expected = os.path.join("/project", "src", "platform-tools")
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_platform_tools_directory_fallback(self):
|
||||
"""Test platform-tools directory fallback behavior."""
|
||||
with patch.object(sys, 'frozen', False, create=True):
|
||||
with patch('src.core.platform_utils.get_executable_directory', return_value='/somewhere'):
|
||||
with patch('os.path.exists', return_value=False):
|
||||
with patch.object(sys, "frozen", False, create=True):
|
||||
with patch(
|
||||
"src.core.platform_utils.get_executable_directory",
|
||||
return_value="/somewhere",
|
||||
):
|
||||
with patch("os.path.exists", return_value=False):
|
||||
result = get_platform_tools_directory()
|
||||
expected = os.path.join('/somewhere', 'src', 'platform-tools')
|
||||
assert result == expected
|
||||
expected = os.path.join("/somewhere", "src", "platform-tools")
|
||||
assert result == expected
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.core.progress_tracker import ProgressTracker
|
||||
|
||||
class TestProgressTracker:
|
||||
"""Test progress tracking functionality."""
|
||||
|
||||
|
||||
def test_init(self):
|
||||
"""Test ProgressTracker initialization."""
|
||||
tracker = ProgressTracker()
|
||||
@@ -19,111 +19,111 @@ class TestProgressTracker:
|
||||
assert tracker.transferred_bytes == 0
|
||||
assert tracker.current_speed == 0.0
|
||||
assert tracker.estimated_time_remaining == 0
|
||||
|
||||
|
||||
def test_start_tracking(self):
|
||||
"""Test starting progress tracking."""
|
||||
tracker = ProgressTracker()
|
||||
with patch('time.time', return_value=1000.0):
|
||||
with patch("time.time", return_value=1000.0):
|
||||
tracker.start_tracking(1024)
|
||||
|
||||
|
||||
assert tracker.start_time == 1000.0
|
||||
assert tracker.total_bytes == 1024
|
||||
assert tracker.transferred_bytes == 0
|
||||
|
||||
|
||||
def test_update_progress_first_update(self):
|
||||
"""Test first progress update."""
|
||||
tracker = ProgressTracker()
|
||||
with patch('time.time', return_value=1000.0):
|
||||
with patch("time.time", return_value=1000.0):
|
||||
tracker.start_tracking(1024)
|
||||
|
||||
with patch('time.time', return_value=1001.0):
|
||||
|
||||
with patch("time.time", return_value=1001.0):
|
||||
tracker.update_progress(512)
|
||||
|
||||
|
||||
assert tracker.transferred_bytes == 512
|
||||
assert tracker.current_speed == 512.0 # 512 bytes in 1 second
|
||||
|
||||
|
||||
def test_update_progress_multiple_updates(self):
|
||||
"""Test multiple progress updates."""
|
||||
tracker = ProgressTracker()
|
||||
with patch('time.time', return_value=1000.0):
|
||||
with patch("time.time", return_value=1000.0):
|
||||
tracker.start_tracking(1024)
|
||||
|
||||
with patch('time.time', return_value=1001.0):
|
||||
|
||||
with patch("time.time", return_value=1001.0):
|
||||
tracker.update_progress(256)
|
||||
|
||||
with patch('time.time', return_value=1002.0):
|
||||
|
||||
with patch("time.time", return_value=1002.0):
|
||||
tracker.update_progress(512)
|
||||
|
||||
|
||||
assert tracker.transferred_bytes == 512
|
||||
assert tracker.current_speed == 256.0 # Average speed
|
||||
|
||||
|
||||
def test_update_progress_zero_time_elapsed(self):
|
||||
"""Test progress update with zero time elapsed."""
|
||||
tracker = ProgressTracker()
|
||||
with patch('time.time', return_value=1000.0):
|
||||
with patch("time.time", return_value=1000.0):
|
||||
tracker.start_tracking(1024)
|
||||
tracker.update_progress(512)
|
||||
|
||||
|
||||
assert tracker.transferred_bytes == 512
|
||||
assert tracker.current_speed == 0.0 # No time elapsed
|
||||
|
||||
|
||||
def test_get_progress_percentage_no_total(self):
|
||||
"""Test getting progress percentage with no total bytes."""
|
||||
tracker = ProgressTracker()
|
||||
assert tracker.get_progress_percentage() == 0.0
|
||||
|
||||
|
||||
def test_get_progress_percentage_with_progress(self):
|
||||
"""Test getting progress percentage with progress."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 250
|
||||
|
||||
|
||||
assert tracker.get_progress_percentage() == 25.0
|
||||
|
||||
|
||||
def test_get_progress_percentage_complete(self):
|
||||
"""Test getting progress percentage when complete."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 1000
|
||||
|
||||
|
||||
assert tracker.get_progress_percentage() == 100.0
|
||||
|
||||
|
||||
def test_get_progress_percentage_over_100(self):
|
||||
"""Test getting progress percentage over 100%."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 1200
|
||||
|
||||
|
||||
assert tracker.get_progress_percentage() == 100.0 # Capped at 100%
|
||||
|
||||
|
||||
def test_estimate_time_remaining_no_speed(self):
|
||||
"""Test time estimation with no speed."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 250
|
||||
tracker.current_speed = 0.0
|
||||
|
||||
|
||||
assert tracker.estimate_time_remaining() == 0
|
||||
|
||||
|
||||
def test_estimate_time_remaining_with_speed(self):
|
||||
"""Test time estimation with speed."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 250
|
||||
tracker.current_speed = 125.0 # 125 bytes/second
|
||||
|
||||
|
||||
remaining_time = tracker.estimate_time_remaining()
|
||||
assert remaining_time == 6 # (1000-250)/125 = 6 seconds
|
||||
|
||||
|
||||
def test_estimate_time_remaining_complete(self):
|
||||
"""Test time estimation when transfer is complete."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 1000
|
||||
tracker.current_speed = 100.0
|
||||
|
||||
|
||||
assert tracker.estimate_time_remaining() == 0
|
||||
|
||||
|
||||
def test_reset_tracking(self):
|
||||
"""Test resetting progress tracking."""
|
||||
tracker = ProgressTracker()
|
||||
@@ -131,53 +131,53 @@ class TestProgressTracker:
|
||||
tracker.total_bytes = 1000
|
||||
tracker.transferred_bytes = 500
|
||||
tracker.current_speed = 100.0
|
||||
|
||||
|
||||
tracker.reset()
|
||||
|
||||
|
||||
assert tracker.start_time is None
|
||||
assert tracker.total_bytes == 0
|
||||
assert tracker.transferred_bytes == 0
|
||||
assert tracker.current_speed == 0.0
|
||||
assert tracker.estimated_time_remaining == 0
|
||||
|
||||
|
||||
def test_format_speed_bytes(self):
|
||||
"""Test formatting speed in bytes per second."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.current_speed = 512.0
|
||||
|
||||
|
||||
formatted = tracker.format_speed()
|
||||
assert formatted == "512.0 B/s"
|
||||
|
||||
|
||||
def test_format_speed_kilobytes(self):
|
||||
"""Test formatting speed in kilobytes per second."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.current_speed = 1536.0 # 1.5 KB/s
|
||||
|
||||
|
||||
formatted = tracker.format_speed()
|
||||
assert formatted == "1.5 KB/s"
|
||||
|
||||
|
||||
def test_format_speed_megabytes(self):
|
||||
"""Test formatting speed in megabytes per second."""
|
||||
tracker = ProgressTracker()
|
||||
tracker.current_speed = 2097152.0 # 2 MB/s
|
||||
|
||||
|
||||
formatted = tracker.format_speed()
|
||||
assert formatted == "2.0 MB/s"
|
||||
|
||||
|
||||
def test_format_time_seconds(self):
|
||||
"""Test formatting time in seconds."""
|
||||
tracker = ProgressTracker()
|
||||
formatted = tracker.format_time(30)
|
||||
assert formatted == "00:30"
|
||||
|
||||
|
||||
def test_format_time_minutes(self):
|
||||
"""Test formatting time in minutes and seconds."""
|
||||
tracker = ProgressTracker()
|
||||
formatted = tracker.format_time(150) # 2:30
|
||||
assert formatted == "02:30"
|
||||
|
||||
|
||||
def test_format_time_hours(self):
|
||||
"""Test formatting time in hours, minutes and seconds."""
|
||||
tracker = ProgressTracker()
|
||||
formatted = tracker.format_time(3661) # 1:01:01
|
||||
assert formatted == "01:01:01"
|
||||
assert formatted == "01:01:01"
|
||||
|
||||
@@ -9,259 +9,287 @@ from src.managers.device_manager import DeviceManager
|
||||
|
||||
class TestDeviceManager:
|
||||
"""Test DeviceManager class functionality."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_parent_window(self):
|
||||
"""Create a mock parent window for testing."""
|
||||
return MagicMock(spec=tk.Tk)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_status_callback(self):
|
||||
"""Create a mock status callback for testing."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_manager(self, mock_parent_window, mock_status_callback):
|
||||
"""Create a DeviceManager instance for testing."""
|
||||
with patch('src.managers.device_manager.ADBManager') as mock_adb:
|
||||
with patch("src.managers.device_manager.ADBManager") as mock_adb:
|
||||
manager = DeviceManager(mock_parent_window, mock_status_callback)
|
||||
manager.adb_manager = mock_adb.return_value
|
||||
return manager
|
||||
|
||||
|
||||
def test_init(self, mock_parent_window, mock_status_callback):
|
||||
"""Test DeviceManager initialization."""
|
||||
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
|
||||
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
|
||||
mock_adb_instance = MagicMock()
|
||||
mock_adb_class.return_value = mock_adb_instance
|
||||
|
||||
|
||||
device_manager = DeviceManager(mock_parent_window, mock_status_callback)
|
||||
|
||||
|
||||
assert device_manager.parent == mock_parent_window
|
||||
assert device_manager.status_callback == mock_status_callback
|
||||
assert device_manager.device_connected is False
|
||||
|
||||
|
||||
# Verify ADB callbacks are set
|
||||
mock_adb_instance.set_status_callback.assert_called_once()
|
||||
mock_adb_instance.set_progress_callback.assert_called_once()
|
||||
|
||||
|
||||
def test_init_without_status_callback(self, mock_parent_window):
|
||||
"""Test DeviceManager initialization without status callback."""
|
||||
with patch('src.managers.device_manager.ADBManager'):
|
||||
with patch("src.managers.device_manager.ADBManager"):
|
||||
device_manager = DeviceManager(mock_parent_window)
|
||||
assert device_manager.status_callback is None
|
||||
|
||||
@patch('src.managers.device_manager.is_adb_available', return_value=True)
|
||||
|
||||
@patch("src.managers.device_manager.is_adb_available", return_value=True)
|
||||
def test_initialize_adb_already_available(self, mock_is_available, device_manager):
|
||||
"""Test ADB initialization when ADB is already available."""
|
||||
result = device_manager.initialize_adb()
|
||||
assert result is True
|
||||
|
||||
@patch('src.managers.device_manager.is_adb_available')
|
||||
@patch('src.managers.device_manager.messagebox.showinfo')
|
||||
def test_initialize_adb_needs_download_success(self, mock_showinfo, mock_is_available, device_manager):
|
||||
|
||||
@patch("src.managers.device_manager.is_adb_available")
|
||||
@patch("src.managers.device_manager.messagebox.showinfo")
|
||||
def test_initialize_adb_needs_download_success(
|
||||
self, mock_showinfo, mock_is_available, device_manager
|
||||
):
|
||||
"""Test ADB initialization when download is needed and succeeds."""
|
||||
# Mock is_adb_available to return False first (triggering download), then True (after download)
|
||||
mock_is_available.side_effect = [False, True]
|
||||
device_manager.adb_manager.download_and_extract_adb.return_value = True
|
||||
|
||||
|
||||
result = device_manager.initialize_adb()
|
||||
|
||||
|
||||
# Should show welcome message
|
||||
mock_showinfo.assert_called_once()
|
||||
assert "Welcome to Android File Transfer!" in mock_showinfo.call_args[0][0]
|
||||
|
||||
|
||||
# Should attempt download
|
||||
device_manager.adb_manager.download_and_extract_adb.assert_called_once()
|
||||
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch('src.managers.device_manager.is_adb_available', return_value=False)
|
||||
@patch('src.managers.device_manager.messagebox.showinfo')
|
||||
@patch('src.managers.device_manager.messagebox.showerror')
|
||||
def test_initialize_adb_needs_download_failure(self, mock_showerror, mock_showinfo, mock_is_available, device_manager):
|
||||
|
||||
@patch("src.managers.device_manager.is_adb_available", return_value=False)
|
||||
@patch("src.managers.device_manager.messagebox.showinfo")
|
||||
@patch("src.managers.device_manager.messagebox.showerror")
|
||||
def test_initialize_adb_needs_download_failure(
|
||||
self, mock_showerror, mock_showinfo, mock_is_available, device_manager
|
||||
):
|
||||
"""Test ADB initialization when download fails."""
|
||||
device_manager.adb_manager.download_and_extract_adb.return_value = False
|
||||
|
||||
|
||||
result = device_manager.initialize_adb()
|
||||
|
||||
|
||||
# Should show welcome message
|
||||
mock_showinfo.assert_called_once()
|
||||
|
||||
|
||||
# Should attempt download
|
||||
device_manager.adb_manager.download_and_extract_adb.assert_called_once()
|
||||
|
||||
|
||||
# Should show error message
|
||||
mock_showerror.assert_called_once()
|
||||
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_check_device_connection_connected(self, device_manager):
|
||||
"""Test device connection check when device is connected."""
|
||||
device_manager.adb_manager.check_device.return_value = "ABC123"
|
||||
|
||||
|
||||
result = device_manager.check_device_connection()
|
||||
|
||||
|
||||
assert result == "ABC123" # Returns the device ID, not a boolean
|
||||
assert device_manager.device_connected is True
|
||||
device_manager.adb_manager.check_device.assert_called_once()
|
||||
|
||||
|
||||
def test_check_device_connection_not_connected(self, device_manager):
|
||||
"""Test device connection check when device is not connected."""
|
||||
device_manager.adb_manager.check_device.return_value = None
|
||||
|
||||
|
||||
result = device_manager.check_device_connection()
|
||||
|
||||
|
||||
assert result is None # Returns None, not False
|
||||
assert device_manager.device_connected is False
|
||||
device_manager.adb_manager.check_device.assert_called_once()
|
||||
|
||||
|
||||
def test_check_device_connection_exception(self, device_manager):
|
||||
"""Test device connection check when exception occurs."""
|
||||
device_manager.adb_manager.check_device.side_effect = Exception("Connection error")
|
||||
|
||||
device_manager.adb_manager.check_device.side_effect = Exception(
|
||||
"Connection error"
|
||||
)
|
||||
|
||||
# The method doesn't catch exceptions, so it should raise
|
||||
with pytest.raises(Exception, match="Connection error"):
|
||||
device_manager.check_device_connection()
|
||||
|
||||
|
||||
def test_device_connected_property_after_connection(self, device_manager):
|
||||
"""Test device_connected property after successful connection."""
|
||||
device_manager.adb_manager.check_device.return_value = "TEST123"
|
||||
|
||||
|
||||
device_id = device_manager.check_device_connection()
|
||||
|
||||
|
||||
assert device_id == "TEST123"
|
||||
assert device_manager.device_connected is True
|
||||
|
||||
|
||||
def test_device_connected_property_after_failed_connection(self, device_manager):
|
||||
"""Test device_connected property after failed connection."""
|
||||
device_manager.adb_manager.check_device.return_value = None
|
||||
|
||||
|
||||
device_id = device_manager.check_device_connection()
|
||||
|
||||
|
||||
assert device_id is None
|
||||
assert device_manager.device_connected is False
|
||||
|
||||
|
||||
def test_on_adb_status_update_with_callback(self, device_manager):
|
||||
"""Test ADB status update with callback."""
|
||||
device_manager._on_adb_status_update("Test status")
|
||||
|
||||
|
||||
device_manager.status_callback.assert_called_once_with("Test status")
|
||||
|
||||
|
||||
def test_on_adb_status_update_without_callback(self, device_manager):
|
||||
"""Test ADB status update without callback."""
|
||||
device_manager.status_callback = None
|
||||
|
||||
|
||||
# Should not raise an exception
|
||||
device_manager._on_adb_status_update("Test status")
|
||||
|
||||
|
||||
def test_on_adb_progress_update(self, device_manager):
|
||||
"""Test ADB progress update."""
|
||||
# This method currently just passes through, so we test it doesn't crash
|
||||
device_manager._on_adb_progress_update(50)
|
||||
# No assertions needed as the method doesn't do anything currently
|
||||
|
||||
|
||||
def test_is_remote_file_detects_file(self, device_manager):
|
||||
"""Test is_remote_file correctly identifies a file."""
|
||||
# Mock adb command to return file listing (starts with '-' for files)
|
||||
device_manager.adb_manager.run_adb_command.return_value = ("-rw-r--r-- 1 root root 1234 test.txt", "", 0)
|
||||
|
||||
device_manager.adb_manager.run_adb_command.return_value = (
|
||||
"-rw-r--r-- 1 root root 1234 test.txt",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
|
||||
result = device_manager.is_remote_file("/sdcard/test.txt")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_is_remote_file_detects_directory(self, device_manager):
|
||||
"""Test is_remote_file correctly identifies a directory."""
|
||||
# Mock adb command to return directory listing (starts with 'd' for directories)
|
||||
device_manager.adb_manager.run_adb_command.return_value = ("drwxr-xr-x 1 root root 4096 testdir", "", 0)
|
||||
|
||||
device_manager.adb_manager.run_adb_command.return_value = (
|
||||
"drwxr-xr-x 1 root root 4096 testdir",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
|
||||
result = device_manager.is_remote_file("/sdcard/testdir")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_remote_file_command_fails(self, device_manager):
|
||||
"""Test is_remote_file when ADB command fails."""
|
||||
device_manager.adb_manager.run_adb_command.return_value = ("", "No such file", 1)
|
||||
|
||||
device_manager.adb_manager.run_adb_command.return_value = (
|
||||
"",
|
||||
"No such file",
|
||||
1,
|
||||
)
|
||||
|
||||
result = device_manager.is_remote_file("/sdcard/nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_get_file_transfer_methods_push_file(self, device_manager):
|
||||
"""Test getting file transfer methods for pushing a file."""
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods("push", True)
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods(
|
||||
"push", True
|
||||
)
|
||||
assert transfer_type == "file"
|
||||
assert method_func == device_manager.adb_manager.push_file
|
||||
|
||||
|
||||
def test_get_file_transfer_methods_pull_folder(self, device_manager):
|
||||
"""Test getting file transfer methods for pulling a folder."""
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods("pull", False)
|
||||
assert transfer_type == "folder"
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods(
|
||||
"pull", False
|
||||
)
|
||||
assert transfer_type == "folder"
|
||||
assert method_func == device_manager.adb_manager.pull_folder_with_dedup
|
||||
|
||||
|
||||
def test_get_file_transfer_methods_pull_file(self, device_manager):
|
||||
"""Test getting file transfer methods for pulling a file."""
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods("pull", True)
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods(
|
||||
"pull", True
|
||||
)
|
||||
assert transfer_type == "file"
|
||||
assert method_func == device_manager.adb_manager.pull_file
|
||||
|
||||
|
||||
def test_get_file_transfer_methods_push_folder(self, device_manager):
|
||||
"""Test getting file transfer methods for pushing a folder."""
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods("push", False)
|
||||
method_func, transfer_type = device_manager.get_file_transfer_methods(
|
||||
"push", False
|
||||
)
|
||||
assert transfer_type == "folder"
|
||||
assert method_func == device_manager.adb_manager.push_folder_with_dedup
|
||||
|
||||
|
||||
def test_cancel_current_operation(self, device_manager):
|
||||
"""Test canceling current ADB operation."""
|
||||
# This method returns None, so just test that it calls the right method
|
||||
device_manager.cancel_current_operation()
|
||||
|
||||
|
||||
device_manager.adb_manager.cancel_current_operation.assert_called_once()
|
||||
|
||||
|
||||
def test_device_connected_property_access(self, device_manager):
|
||||
"""Test device_connected property access."""
|
||||
# Test initial state
|
||||
assert hasattr(device_manager, 'device_connected')
|
||||
|
||||
# Test setting the property
|
||||
assert hasattr(device_manager, "device_connected")
|
||||
|
||||
# Test setting the property
|
||||
device_manager.device_connected = True
|
||||
assert device_manager.device_connected is True
|
||||
|
||||
|
||||
device_manager.device_connected = False
|
||||
assert device_manager.device_connected is False
|
||||
|
||||
|
||||
def test_adb_manager_property_access(self, device_manager):
|
||||
"""Test accessing ADB manager property."""
|
||||
assert hasattr(device_manager, 'adb_manager')
|
||||
assert hasattr(device_manager, "adb_manager")
|
||||
assert device_manager.adb_manager is not None
|
||||
|
||||
|
||||
class TestDeviceManagerIntegration:
|
||||
"""Integration tests for DeviceManager."""
|
||||
|
||||
|
||||
def test_full_initialization_flow(self):
|
||||
"""Test the complete initialization flow."""
|
||||
mock_parent = MagicMock(spec=tk.Tk)
|
||||
mock_callback = MagicMock()
|
||||
|
||||
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
|
||||
with patch('src.managers.device_manager.is_adb_available', return_value=True):
|
||||
|
||||
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
|
||||
with patch(
|
||||
"src.managers.device_manager.is_adb_available", return_value=True
|
||||
):
|
||||
device_manager = DeviceManager(mock_parent, mock_callback)
|
||||
result = device_manager.initialize_adb()
|
||||
|
||||
|
||||
assert result is True
|
||||
assert device_manager.device_connected is False
|
||||
|
||||
|
||||
def test_device_connection_workflow(self):
|
||||
"""Test the device connection workflow."""
|
||||
mock_parent = MagicMock(spec=tk.Tk)
|
||||
|
||||
with patch('src.managers.device_manager.ADBManager') as mock_adb_class:
|
||||
|
||||
with patch("src.managers.device_manager.ADBManager") as mock_adb_class:
|
||||
mock_adb_instance = mock_adb_class.return_value
|
||||
device_manager = DeviceManager(mock_parent)
|
||||
|
||||
|
||||
# Test connection success - should return device ID, not boolean
|
||||
mock_adb_instance.check_device.return_value = "TEST123"
|
||||
result = device_manager.check_device_connection()
|
||||
assert result == "TEST123" # Returns device ID
|
||||
assert device_manager.device_connected is True
|
||||
|
||||
|
||||
# Test connection failure - should return None, not False
|
||||
mock_adb_instance.check_device.return_value = None
|
||||
result = device_manager.check_device_connection()
|
||||
@@ -269,5 +297,5 @@ class TestDeviceManagerIntegration:
|
||||
assert device_manager.device_connected is False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
@@ -11,213 +11,245 @@ from src.utils.file_deduplication import FileDeduplicator
|
||||
|
||||
class TestFileDeduplicator:
|
||||
"""Test FileDeduplicator class functionality."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deduplicator(self):
|
||||
"""Create a FileDeduplicator instance for testing."""
|
||||
return FileDeduplicator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deduplicator_with_callbacks(self):
|
||||
"""Create a FileDeduplicator instance with callbacks for testing."""
|
||||
status_callback = MagicMock()
|
||||
progress_callback = MagicMock()
|
||||
return FileDeduplicator(status_callback, progress_callback), status_callback, progress_callback
|
||||
|
||||
return (
|
||||
FileDeduplicator(status_callback, progress_callback),
|
||||
status_callback,
|
||||
progress_callback,
|
||||
)
|
||||
|
||||
def test_init_without_callbacks(self, deduplicator):
|
||||
"""Test FileDeduplicator initialization without callbacks."""
|
||||
assert deduplicator.status_callback is None
|
||||
assert deduplicator.progress_callback is None
|
||||
|
||||
|
||||
def test_init_with_callbacks(self):
|
||||
"""Test FileDeduplicator initialization with callbacks."""
|
||||
status_callback = MagicMock()
|
||||
progress_callback = MagicMock()
|
||||
deduplicator = FileDeduplicator(status_callback, progress_callback)
|
||||
|
||||
|
||||
assert deduplicator.status_callback == status_callback
|
||||
assert deduplicator.progress_callback == progress_callback
|
||||
|
||||
|
||||
def test_update_status_with_callback(self, deduplicator_with_callbacks):
|
||||
"""Test status update with callback."""
|
||||
deduplicator, status_callback, _ = deduplicator_with_callbacks
|
||||
deduplicator._update_status("Test message")
|
||||
status_callback.assert_called_once_with("Test message")
|
||||
|
||||
|
||||
def test_update_status_without_callback(self, deduplicator):
|
||||
"""Test status update without callback."""
|
||||
# Should not raise an exception
|
||||
deduplicator._update_status("Test message")
|
||||
|
||||
|
||||
def test_update_progress_with_callback(self, deduplicator_with_callbacks):
|
||||
"""Test progress update with callback."""
|
||||
deduplicator, _, progress_callback = deduplicator_with_callbacks
|
||||
deduplicator._update_progress(75)
|
||||
progress_callback.assert_called_once_with(75)
|
||||
|
||||
|
||||
def test_update_progress_without_callback(self, deduplicator):
|
||||
"""Test progress update without callback."""
|
||||
# Should not raise an exception
|
||||
deduplicator._update_progress(75)
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open, read_data=b'test file content')
|
||||
@patch('os.path.isfile', return_value=True)
|
||||
def test_compute_local_file_hash_small_file(self, mock_isfile, mock_file, deduplicator):
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=b"test file content")
|
||||
@patch("os.path.isfile", return_value=True)
|
||||
def test_compute_local_file_hash_small_file(
|
||||
self, mock_isfile, mock_file, deduplicator
|
||||
):
|
||||
"""Test file hash computation for small file."""
|
||||
expected_hash = hashlib.sha256(b'test file content').hexdigest()
|
||||
result = deduplicator.compute_local_file_hash('/fake/path/file.txt')
|
||||
|
||||
expected_hash = hashlib.sha256(b"test file content").hexdigest()
|
||||
result = deduplicator.compute_local_file_hash("/fake/path/file.txt")
|
||||
|
||||
assert result == expected_hash
|
||||
mock_file.assert_called_once_with('/fake/path/file.txt', 'rb')
|
||||
mock_isfile.assert_called_once_with('/fake/path/file.txt')
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
@patch('os.path.isfile', return_value=True)
|
||||
def test_compute_local_file_hash_large_file(self, mock_isfile, mock_file, deduplicator_with_callbacks):
|
||||
mock_file.assert_called_once_with("/fake/path/file.txt", "rb")
|
||||
mock_isfile.assert_called_once_with("/fake/path/file.txt")
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open)
|
||||
@patch("os.path.isfile", return_value=True)
|
||||
def test_compute_local_file_hash_large_file(
|
||||
self, mock_isfile, mock_file, deduplicator_with_callbacks
|
||||
):
|
||||
"""Test file hash computation for large file with progress updates."""
|
||||
deduplicator, status_callback, progress_callback = deduplicator_with_callbacks
|
||||
|
||||
|
||||
# Mock reading chunks
|
||||
mock_file.return_value.__enter__.return_value.read.side_effect = [
|
||||
b'chunk1' * 1000, # First chunk
|
||||
b'chunk2' * 1000, # Second chunk
|
||||
b'', # EOF
|
||||
b"chunk1" * 1000, # First chunk
|
||||
b"chunk2" * 1000, # Second chunk
|
||||
b"", # EOF
|
||||
]
|
||||
|
||||
result = deduplicator.compute_local_file_hash('/fake/path/largefile.txt')
|
||||
|
||||
|
||||
result = deduplicator.compute_local_file_hash("/fake/path/largefile.txt")
|
||||
|
||||
# Should return a valid hash
|
||||
assert isinstance(result, str)
|
||||
assert len(result) == 64 # SHA256 hex digest length
|
||||
|
||||
@patch('os.path.isfile', return_value=False)
|
||||
|
||||
@patch("os.path.isfile", return_value=False)
|
||||
def test_compute_local_file_hash_nonexistent_file(self, mock_isfile, deduplicator):
|
||||
"""Test file hash computation for nonexistent file."""
|
||||
result = deduplicator.compute_local_file_hash('/fake/nonexistent/file.txt')
|
||||
result = deduplicator.compute_local_file_hash("/fake/nonexistent/file.txt")
|
||||
assert result is None
|
||||
mock_isfile.assert_called_once_with('/fake/nonexistent/file.txt')
|
||||
|
||||
@patch('builtins.open', side_effect=PermissionError("Permission denied"))
|
||||
@patch('os.path.isfile', return_value=True)
|
||||
def test_compute_local_file_hash_permission_error(self, mock_isfile, mock_file, deduplicator):
|
||||
mock_isfile.assert_called_once_with("/fake/nonexistent/file.txt")
|
||||
|
||||
@patch("builtins.open", side_effect=PermissionError("Permission denied"))
|
||||
@patch("os.path.isfile", return_value=True)
|
||||
def test_compute_local_file_hash_permission_error(
|
||||
self, mock_isfile, mock_file, deduplicator
|
||||
):
|
||||
"""Test file hash computation with permission error."""
|
||||
result = deduplicator.compute_local_file_hash('/fake/protected/file.txt')
|
||||
result = deduplicator.compute_local_file_hash("/fake/protected/file.txt")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_files_identical_same_hash(self, deduplicator):
|
||||
"""Test file comparison with identical hashes."""
|
||||
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
|
||||
mock_hash.return_value = 'abc123'
|
||||
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
|
||||
mock_remote_hash.return_value = 'abc123'
|
||||
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
|
||||
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
|
||||
mock_hash.return_value = "abc123"
|
||||
with patch.object(
|
||||
deduplicator, "compute_remote_file_hash"
|
||||
) as mock_remote_hash:
|
||||
mock_remote_hash.return_value = "abc123"
|
||||
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_check_files_identical_different_hash(self, deduplicator):
|
||||
"""Test file comparison with different hashes."""
|
||||
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
|
||||
mock_hash.return_value = 'abc123'
|
||||
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
|
||||
mock_remote_hash.return_value = 'def456'
|
||||
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
|
||||
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
|
||||
mock_hash.return_value = "abc123"
|
||||
with patch.object(
|
||||
deduplicator, "compute_remote_file_hash"
|
||||
) as mock_remote_hash:
|
||||
mock_remote_hash.return_value = "def456"
|
||||
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_check_files_identical_one_hash_none(self, deduplicator):
|
||||
"""Test file comparison with one hash being None."""
|
||||
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
|
||||
mock_hash.return_value = 'abc123'
|
||||
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
|
||||
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
|
||||
mock_hash.return_value = "abc123"
|
||||
with patch.object(
|
||||
deduplicator, "compute_remote_file_hash"
|
||||
) as mock_remote_hash:
|
||||
mock_remote_hash.return_value = None
|
||||
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
|
||||
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_check_files_identical_both_hashes_none(self, deduplicator):
|
||||
"""Test file comparison with both hashes being None."""
|
||||
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
|
||||
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
|
||||
mock_hash.return_value = None
|
||||
with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash:
|
||||
with patch.object(
|
||||
deduplicator, "compute_remote_file_hash"
|
||||
) as mock_remote_hash:
|
||||
mock_remote_hash.return_value = None
|
||||
result = deduplicator.check_files_identical('/file1.txt', '/file2.txt')
|
||||
result = deduplicator.check_files_identical("/file1.txt", "/file2.txt")
|
||||
assert result is False
|
||||
|
||||
@patch('os.path.exists')
|
||||
@patch('os.listdir')
|
||||
@patch('os.path.isfile')
|
||||
def test_build_local_file_hash_map(self, mock_isfile, mock_listdir, mock_exists, deduplicator):
|
||||
|
||||
@patch("os.path.exists")
|
||||
@patch("os.listdir")
|
||||
@patch("os.path.isfile")
|
||||
def test_build_local_file_hash_map(
|
||||
self, mock_isfile, mock_listdir, mock_exists, deduplicator
|
||||
):
|
||||
"""Test getting local file hashes from directory."""
|
||||
mock_exists.return_value = True
|
||||
mock_listdir.return_value = ['file1.txt', 'file2.jpg', 'subdir']
|
||||
mock_isfile.side_effect = lambda x: not x.endswith('subdir')
|
||||
|
||||
file_paths = ['/fake/directory/file1.txt', '/fake/directory/file2.jpg']
|
||||
|
||||
with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash:
|
||||
mock_hash.side_effect = ['hash1', 'hash2']
|
||||
|
||||
mock_listdir.return_value = ["file1.txt", "file2.jpg", "subdir"]
|
||||
mock_isfile.side_effect = lambda x: not x.endswith("subdir")
|
||||
|
||||
file_paths = ["/fake/directory/file1.txt", "/fake/directory/file2.jpg"]
|
||||
|
||||
with patch.object(deduplicator, "compute_local_file_hash") as mock_hash:
|
||||
mock_hash.side_effect = ["hash1", "hash2"]
|
||||
|
||||
result = deduplicator.build_local_file_hash_map(file_paths)
|
||||
|
||||
|
||||
expected = {
|
||||
'/fake/directory/file1.txt': 'hash1',
|
||||
'/fake/directory/file2.jpg': 'hash2'
|
||||
"/fake/directory/file1.txt": "hash1",
|
||||
"/fake/directory/file2.jpg": "hash2",
|
||||
}
|
||||
assert result == expected
|
||||
|
||||
@patch('os.path.exists', return_value=False)
|
||||
def test_build_local_file_hash_map_nonexistent_files(self, mock_exists, deduplicator):
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_build_local_file_hash_map_nonexistent_files(
|
||||
self, mock_exists, deduplicator
|
||||
):
|
||||
"""Test getting local file hashes from nonexistent files."""
|
||||
result = deduplicator.build_local_file_hash_map(['/fake/nonexistent'])
|
||||
result = deduplicator.build_local_file_hash_map(["/fake/nonexistent"])
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_find_duplicate_files_with_duplicates(self, deduplicator):
|
||||
"""Test finding duplicates when duplicates exist."""
|
||||
local_files = ['/local/file1.txt', '/local/file2.jpg', '/local/file3.txt']
|
||||
remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg', '/remote/remote3.txt']
|
||||
|
||||
with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local:
|
||||
with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote:
|
||||
local_files = ["/local/file1.txt", "/local/file2.jpg", "/local/file3.txt"]
|
||||
remote_files = [
|
||||
"/remote/remote1.txt",
|
||||
"/remote/remote2.jpg",
|
||||
"/remote/remote3.txt",
|
||||
]
|
||||
|
||||
with patch.object(deduplicator, "build_local_file_hash_map") as mock_local:
|
||||
with patch.object(
|
||||
deduplicator, "build_remote_file_hash_map"
|
||||
) as mock_remote:
|
||||
mock_local.return_value = {
|
||||
'/local/file1.txt': 'hash1',
|
||||
'/local/file2.jpg': 'hash2',
|
||||
'/local/file3.txt': 'hash3'
|
||||
"/local/file1.txt": "hash1",
|
||||
"/local/file2.jpg": "hash2",
|
||||
"/local/file3.txt": "hash3",
|
||||
}
|
||||
mock_remote.return_value = {
|
||||
'/remote/remote1.txt': 'hash1', # Duplicate of file1.txt
|
||||
'/remote/remote2.jpg': 'hash4', # Unique
|
||||
'/remote/remote3.txt': 'hash3' # Duplicate of file3.txt
|
||||
"/remote/remote1.txt": "hash1", # Duplicate of file1.txt
|
||||
"/remote/remote2.jpg": "hash4", # Unique
|
||||
"/remote/remote3.txt": "hash3", # Duplicate of file3.txt
|
||||
}
|
||||
|
||||
|
||||
files_to_transfer, duplicates = deduplicator.find_duplicate_files(
|
||||
local_files, remote_files, is_remote_target=True)
|
||||
|
||||
local_files, remote_files, is_remote_target=True
|
||||
)
|
||||
|
||||
# Should find 2 duplicates: file1.txt and file3.txt
|
||||
assert len(duplicates) == 2
|
||||
assert '/local/file1.txt' in duplicates
|
||||
assert '/local/file3.txt' in duplicates
|
||||
assert "/local/file1.txt" in duplicates
|
||||
assert "/local/file3.txt" in duplicates
|
||||
# file2.jpg should be transferred since it's not a duplicate
|
||||
assert len(files_to_transfer) == 1
|
||||
assert '/local/file2.jpg' in files_to_transfer
|
||||
|
||||
assert "/local/file2.jpg" in files_to_transfer
|
||||
|
||||
def test_find_duplicate_files_no_duplicates(self, deduplicator):
|
||||
"""Test finding duplicates when no duplicates exist."""
|
||||
local_files = ['/local/file1.txt', '/local/file2.jpg']
|
||||
remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg']
|
||||
|
||||
with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local:
|
||||
with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote:
|
||||
local_files = ["/local/file1.txt", "/local/file2.jpg"]
|
||||
remote_files = ["/remote/remote1.txt", "/remote/remote2.jpg"]
|
||||
|
||||
with patch.object(deduplicator, "build_local_file_hash_map") as mock_local:
|
||||
with patch.object(
|
||||
deduplicator, "build_remote_file_hash_map"
|
||||
) as mock_remote:
|
||||
mock_local.return_value = {
|
||||
'/local/file1.txt': 'hash1',
|
||||
'/local/file2.jpg': 'hash2'
|
||||
"/local/file1.txt": "hash1",
|
||||
"/local/file2.jpg": "hash2",
|
||||
}
|
||||
mock_remote.return_value = {
|
||||
'/remote/remote1.txt': 'hash3',
|
||||
'/remote/remote2.jpg': 'hash4'
|
||||
"/remote/remote1.txt": "hash3",
|
||||
"/remote/remote2.jpg": "hash4",
|
||||
}
|
||||
|
||||
|
||||
files_to_transfer, duplicates = deduplicator.find_duplicate_files(
|
||||
local_files, remote_files, is_remote_target=True)
|
||||
local_files, remote_files, is_remote_target=True
|
||||
)
|
||||
assert duplicates == []
|
||||
assert files_to_transfer == local_files
|
||||
|
||||
|
||||
def test_find_duplicate_files_empty_collections(self, deduplicator):
|
||||
"""Test finding duplicates with empty collections."""
|
||||
files_to_transfer, duplicates = deduplicator.find_duplicate_files([], [])
|
||||
@@ -227,66 +259,66 @@ class TestFileDeduplicator:
|
||||
|
||||
class TestFileDeduplicatorIntegration:
|
||||
"""Integration tests for FileDeduplicator with real files."""
|
||||
|
||||
|
||||
def test_real_file_hash_computation(self):
|
||||
"""Test hash computation with real temporary files."""
|
||||
deduplicator = FileDeduplicator()
|
||||
|
||||
|
||||
# Create temporary files with known content
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf1:
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf1:
|
||||
tf1.write("test content 1")
|
||||
temp_file1 = tf1.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf2:
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf2:
|
||||
tf2.write("test content 1") # Same content
|
||||
temp_file2 = tf2.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf3:
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf3:
|
||||
tf3.write("test content 2") # Different content
|
||||
temp_file3 = tf3.name
|
||||
|
||||
|
||||
try:
|
||||
hash1 = deduplicator.compute_local_file_hash(temp_file1)
|
||||
hash2 = deduplicator.compute_local_file_hash(temp_file2)
|
||||
hash3 = deduplicator.compute_local_file_hash(temp_file3)
|
||||
|
||||
|
||||
# Same content should have same hash
|
||||
assert hash1 == hash2
|
||||
# Different content should have different hash
|
||||
assert hash1 != hash3
|
||||
|
||||
|
||||
# Test file comparison - since check_files_identical expects local/remote,
|
||||
# we'll just compare hashes directly for local files
|
||||
assert hash1 == hash2 # Same files should have same hash
|
||||
assert hash1 != hash3 # Different files should have different hash
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
os.unlink(temp_file1)
|
||||
os.unlink(temp_file2)
|
||||
os.unlink(temp_file3)
|
||||
|
||||
|
||||
def test_real_directory_hash_computation(self):
|
||||
"""Test directory hash computation with real temporary directory."""
|
||||
deduplicator = FileDeduplicator()
|
||||
|
||||
|
||||
# Create temporary directory with files
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
file1_path = os.path.join(temp_dir, 'file1.txt')
|
||||
file2_path = os.path.join(temp_dir, 'file2.txt')
|
||||
|
||||
with open(file1_path, 'w') as f1:
|
||||
file1_path = os.path.join(temp_dir, "file1.txt")
|
||||
file2_path = os.path.join(temp_dir, "file2.txt")
|
||||
|
||||
with open(file1_path, "w") as f1:
|
||||
f1.write("content 1")
|
||||
with open(file2_path, 'w') as f2:
|
||||
with open(file2_path, "w") as f2:
|
||||
f2.write("content 2")
|
||||
|
||||
|
||||
hashes = deduplicator.build_local_file_hash_map([file1_path, file2_path])
|
||||
|
||||
|
||||
assert file1_path in hashes
|
||||
assert file2_path in hashes
|
||||
assert len(hashes) == 2
|
||||
assert hashes[file1_path] != hashes[file2_path]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestSanitizePathComponent:
|
||||
|
||||
def test_dangerous_chars(self):
|
||||
"""Test that dangerous characters are rejected."""
|
||||
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', '>', '<', '(', ')']
|
||||
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", ">", "<", "(", ")"]
|
||||
for char in dangerous_chars:
|
||||
with pytest.raises(ValueError, match="dangerous character"):
|
||||
sanitize_path_component(f"file{char}name.txt")
|
||||
@@ -60,8 +60,14 @@ class TestSanitizeAndroidPath:
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""Test that paths with spaces are allowed."""
|
||||
assert sanitize_android_path("/sdcard/My Photos/vacation.jpg") == "/sdcard/My Photos/vacation.jpg"
|
||||
assert sanitize_android_path("/sdcard/DCIM/Camera Roll/IMG_001.jpg") == "/sdcard/DCIM/Camera Roll/IMG_001.jpg"
|
||||
assert (
|
||||
sanitize_android_path("/sdcard/My Photos/vacation.jpg")
|
||||
== "/sdcard/My Photos/vacation.jpg"
|
||||
)
|
||||
assert (
|
||||
sanitize_android_path("/sdcard/DCIM/Camera Roll/IMG_001.jpg")
|
||||
== "/sdcard/DCIM/Camera Roll/IMG_001.jpg"
|
||||
)
|
||||
|
||||
def test_empty_path(self):
|
||||
"""Test that empty paths are rejected."""
|
||||
@@ -150,6 +156,7 @@ class TestSanitizeLocalPath:
|
||||
def test_existing_path_resolves_symlinks(self):
|
||||
"""Test that existing paths still resolve symlinks."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a real directory
|
||||
real_dir = os.path.join(tmpdir, "real")
|
||||
@@ -168,6 +175,7 @@ class TestSanitizeLocalPath:
|
||||
def test_nonexistent_path_strict_mode(self):
|
||||
"""Test that strict mode (allow_nonexistent=False) works for existing paths."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Test with existing directory
|
||||
result = sanitize_local_path(tmpdir, allow_nonexistent=False)
|
||||
@@ -190,7 +198,7 @@ class TestValidateDeviceId:
|
||||
|
||||
def test_invalid_characters(self):
|
||||
"""Test that invalid characters are rejected."""
|
||||
dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<']
|
||||
dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"]
|
||||
for char in dangerous_chars:
|
||||
with pytest.raises(ValueError):
|
||||
validate_device_id(f"device{char}123")
|
||||
@@ -222,6 +230,7 @@ class TestSecurityIntegration:
|
||||
def test_symlink_attack_prevention(self):
|
||||
"""Test that symlink-based path traversal is blocked."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a base directory
|
||||
base_dir = os.path.join(tmpdir, "safe")
|
||||
@@ -293,6 +302,7 @@ class TestUnicodeAndEdgeCases:
|
||||
def test_local_path_windows_style(self):
|
||||
"""Test that Windows-style paths are normalized correctly."""
|
||||
import platform
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Windows paths should be normalized
|
||||
result = sanitize_local_path("C:\\Users\\Test\\Documents")
|
||||
@@ -307,6 +317,7 @@ class TestUnicodeAndEdgeCases:
|
||||
def test_local_path_with_mixed_separators(self):
|
||||
"""Test that paths with mixed separators are normalized."""
|
||||
import platform
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Windows should handle mixed separators
|
||||
mixed_path = "C:/Users\\Test/Documents"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Vagrant Windows 11 Test Environment
|
||||
|
||||
Automated testing of the Android File Handler installer on a clean Windows 11 VM using Hyper-V.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Vagrant](https://www.vagrantup.com/) >= 2.4
|
||||
- Hyper-V enabled (`Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All`)
|
||||
- [Inno Setup 6](https://jrsoftware.org/isdl.php) installed (default path or ISCC.exe in PATH)
|
||||
- Poetry environment set up (`poetry install`)
|
||||
|
||||
## Setup (one-time)
|
||||
|
||||
```powershell
|
||||
cd vagrant
|
||||
vagrant up --provider=hyperv
|
||||
vagrant snapshot save clean
|
||||
```
|
||||
|
||||
> **Note:** Vagrant will prompt for your Windows credentials to set up the SMB synced folder.
|
||||
> You can set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to skip the prompt.
|
||||
|
||||
## Running Tests
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
.\scripts\windows\run_vagrant_tests.ps1
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Build the installer locally (PyInstaller + Inno Setup)
|
||||
2. Restore the VM to the `clean` snapshot
|
||||
3. Push the installer to the VM via WinRM
|
||||
4. Run the installer silently inside the VM
|
||||
5. Verify installation (directory, exe, shortcuts, registry)
|
||||
6. Launch the app, find the window, take a GUI screenshot
|
||||
7. Run silent uninstall and verify cleanup
|
||||
8. Retrieve results CSV + screenshots to `vagrant_test_results\<timestamp>\`
|
||||
9. Print a summary table to the CLI
|
||||
10. Revert the VM back to the `clean` snapshot
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `-SkipBuild` | Skip the local build step (use an existing installer in `dist\`) |
|
||||
| `-SkipRevert` | Leave the VM in post-test state (useful for debugging) |
|
||||
| `-SnapshotName <name>` | Use a different snapshot name (default: `clean`) |
|
||||
| `-LocalResultsDir <path>` | Override where results are saved |
|
||||
|
||||
## What Gets Tested
|
||||
|
||||
| # | Test | Description |
|
||||
|---|---|---|
|
||||
| 1 | Installer exists | Checks the built `.exe` is present |
|
||||
| 2 | Silent install | Runs the Inno Setup installer with `/VERYSILENT` |
|
||||
| 3 | Install directory | Verifies `C:\Program Files\Android File Handler` exists |
|
||||
| 4 | Executable present | Checks the app `.exe` is in the install directory |
|
||||
| 5 | Start Menu shortcut | Verifies the Start Menu entry was created |
|
||||
| 6 | Registry entry | Checks Add/Remove Programs registration |
|
||||
| 7 | App launches | Starts the app and confirms it stays running |
|
||||
| 8 | Window found | Finds the main window by its title |
|
||||
| 9 | Silent uninstall | Runs the uninstaller and verifies cleanup |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
vagrant/
|
||||
├── Vagrantfile # VM definition (Windows 11 Enterprise, Hyper-V)
|
||||
├── provision.ps1 # VM provisioning (nircmd for screenshots)
|
||||
├── test_installer.ps1 # Test script that runs inside the VM
|
||||
└── README.md # This file
|
||||
scripts/windows/
|
||||
└── run_vagrant_tests.ps1 # Host-side orchestrator (builds, pushes, tests, reverts)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**VM won't start**: Ensure Hyper-V is enabled and you are running the terminal as Administrator.
|
||||
|
||||
**WinRM connection fails**: The box uses plaintext WinRM on port 55985. Ensure no firewall blocks it.
|
||||
|
||||
**SMB share prompt**: Set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to avoid interactive credential prompts.
|
||||
|
||||
**Inno Setup not found**: Install Inno Setup 6 to the default path or add `ISCC.exe` to your PATH.
|
||||
|
||||
**Want to inspect the VM manually**: Use `vagrant rdp` or pass `-SkipRevert`.
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
#
|
||||
# Vagrant Windows 11 test machine for Android File Handler installer testing.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Vagrant >= 2.4
|
||||
# - Hyper-V enabled (Windows host)
|
||||
#
|
||||
# Usage:
|
||||
# cd vagrant
|
||||
# vagrant up --provider=hyperv
|
||||
# vagrant snapshot save clean
|
||||
# ..\scripts\windows\run_vagrant_tests.ps1
|
||||
#
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
config.vm.box = "gusztavvargadr/windows-11-24h2-enterprise"
|
||||
|
||||
config.vm.hostname = "afh-test"
|
||||
config.vm.communicator = "winrm"
|
||||
|
||||
config.winrm.username = "vagrant"
|
||||
config.winrm.password = "vagrant"
|
||||
config.winrm.transport = :plaintext # Vagrant's own comms (boot check, provision)
|
||||
config.winrm.basic_auth_only = true
|
||||
|
||||
config.vm.guest = :windows
|
||||
config.vm.boot_timeout = 900
|
||||
config.vm.graceful_halt_timeout = 120
|
||||
|
||||
# Automatically select the Default Switch for Hyper-V to avoid interactive prompt
|
||||
config.vm.network "public_network", bridge: "Default Switch"
|
||||
|
||||
config.vm.network "forwarded_port", guest: 5985, host: 55985 # Vagrant plaintext WinRM
|
||||
config.vm.network "forwarded_port", guest: 5986, host: 55986 # PSSession HTTPS WinRM
|
||||
|
||||
config.vm.provider "hyperv" do |hv|
|
||||
hv.vmname = "afh-win11-test"
|
||||
hv.memory = 8192
|
||||
hv.maxmemory = 8192
|
||||
hv.cpus = 4
|
||||
hv.enable_enhanced_session_mode = true
|
||||
hv.linked_clone = true
|
||||
end
|
||||
|
||||
# Disable default Vagrant synced folder to prevent SMB credential prompts.
|
||||
# Files are transferred directly via PSSession by run_vagrant_tests.ps1.
|
||||
config.vm.synced_folder ".", "/vagrant", disabled: true
|
||||
|
||||
config.vm.provision "shell",
|
||||
path: "provision.ps1",
|
||||
privileged: true
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
# provision.ps1 — Vagrant provisioning script for Windows 11 test VM.
|
||||
# Installs prerequisites needed to test the Android File Handler installer.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== Provisioning Android File Handler test VM ===" -ForegroundColor Cyan
|
||||
|
||||
# --- Enable auto-logon so GUI tests work after reboot ---
|
||||
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
|
||||
Set-ItemProperty -Path $RegPath -Name AutoAdminLogon -Value "1"
|
||||
Set-ItemProperty -Path $RegPath -Name DefaultUserName -Value "vagrant"
|
||||
Set-ItemProperty -Path $RegPath -Name DefaultPassword -Value "vagrant"
|
||||
|
||||
# --- Allow script execution (required for test suite) ---
|
||||
# Write the LocalMachine execution policy directly via the registry instead of using
|
||||
# Set-ExecutionPolicy. Vagrant runs provision.ps1 under "powershell -ExecutionPolicy Bypass",
|
||||
# which sets a Process-scope policy. When a Process-scope policy is active,
|
||||
# Set-ExecutionPolicy -Scope LocalMachine throws a terminating SecurityException
|
||||
# ("overridden by a policy defined at a more specific scope") that cannot be suppressed
|
||||
# with -ErrorAction. Writing the registry key bypasses that cmdlet restriction entirely.
|
||||
# The value takes effect in all subsequent WinRM PSSessions (which have no Process-scope
|
||||
# override) — exactly the sessions that run the test suite.
|
||||
$psMachinePolicyPath = 'HKLM:\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell'
|
||||
Set-ItemProperty -Path $psMachinePolicyPath -Name 'ExecutionPolicy' -Value 'Unrestricted' -Force
|
||||
Write-Host " Execution policy set to Unrestricted (LocalMachine)." -ForegroundColor DarkGray
|
||||
|
||||
# --- Install Chocolatey (package manager) ---
|
||||
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Installing Chocolatey..." -ForegroundColor Yellow
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
}
|
||||
|
||||
# --- Configure HTTPS WinRM listener ---
|
||||
# HTTP WinRM (plaintext) causes PowerShell PSSession to reject unencrypted
|
||||
# traffic. A self-signed cert on port 5986 satisfies WinRM's security policy
|
||||
# without needing a domain CA. The 10-year expiry covers long-lived test boxes.
|
||||
Write-Host "Configuring HTTPS WinRM listener..." -ForegroundColor Yellow
|
||||
|
||||
# Ensure network is Private so WinRM HTTPS works correctly
|
||||
Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private
|
||||
|
||||
# Allow local accounts to perform administrative tasks via WinRM
|
||||
# (Required for the 'vagrant' user to have full admin rights over PSSession)
|
||||
$policyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
|
||||
Set-ItemProperty -Path $policyPath -Name LocalAccountTokenFilterPolicy -Value 1 -Force
|
||||
|
||||
# Create certificate for HTTPS listener
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-DnsName "afh-test", "localhost" `
|
||||
-CertStoreLocation "Cert:\LocalMachine\My" `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048 `
|
||||
-NotAfter (Get-Date).AddYears(10)
|
||||
|
||||
# Check if an HTTPS listener already exists
|
||||
$listeners = winrm enumerate winrm/config/listener
|
||||
$hasHttps = $listeners -match "Transport = HTTPS"
|
||||
|
||||
if ($hasHttps) {
|
||||
# If the thumbprint matches, we're already good
|
||||
if ($listeners -match $cert.Thumbprint) {
|
||||
Write-Host " HTTPS listener already correctly configured." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " Updating existing HTTPS listener with new certificate..." -ForegroundColor Gray
|
||||
winrm delete winrm/config/Listener?Address=*+Transport=HTTPS
|
||||
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
|
||||
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
|
||||
}
|
||||
} else {
|
||||
Write-Host " Creating new HTTPS listener..." -ForegroundColor Gray
|
||||
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
|
||||
"@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}"
|
||||
}
|
||||
|
||||
winrm set winrm/config/service/Auth '@{Basic="true"}'
|
||||
|
||||
netsh advfirewall firewall add rule `
|
||||
name="WinRM HTTPS" protocol=TCP dir=in localport=5986 action=allow profile=any | Out-Null
|
||||
|
||||
Write-Host " HTTPS WinRM listener ready on port 5986" -ForegroundColor Green
|
||||
|
||||
# --- Install nircmd for screenshot capture ---
|
||||
Write-Host "Installing nircmd for screenshot capture..." -ForegroundColor Yellow
|
||||
choco install nircmd -y --no-progress
|
||||
|
||||
# --- Install Inno Setup 6.7.1 (pinned) ---
|
||||
Write-Host "Installing Inno Setup 6.7.1..." -ForegroundColor Yellow
|
||||
choco install innosetup --version 6.7.1 -y --no-progress
|
||||
|
||||
# Refresh PATH
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
|
||||
Write-Host "=== Provisioning complete ===" -ForegroundColor Green
|
||||
@@ -0,0 +1,332 @@
|
||||
# test_installer.ps1 — Runs INSIDE the Vagrant VM via WinRM.
|
||||
# Tests installation and basic function of Android File Handler.
|
||||
#
|
||||
# Exit codes: 0 = all passed, 1 = one or more failures.
|
||||
|
||||
param(
|
||||
[string]$InstallerPath = "C:\vagrant_build\dist\android-file-handler-setup.exe",
|
||||
[string]$ScreenshotDir = "C:\vagrant_test_results",
|
||||
[string]$AppVersion = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
$Script:Passed = 0
|
||||
$Script:Failed = 0
|
||||
$Script:Results = @()
|
||||
|
||||
function Write-TestResult {
|
||||
param([string]$Name, [bool]$Pass, [string]$Detail = "")
|
||||
$status = if ($Pass) { "PASS" } else { "FAIL" }
|
||||
$color = if ($Pass) { "Green" } else { "Red" }
|
||||
$msg = "[$status] $Name"
|
||||
if ($Detail) { $msg += " - $Detail" }
|
||||
Write-Host $msg -ForegroundColor $color
|
||||
if ($Pass) { $Script:Passed++ } else { $Script:Failed++ }
|
||||
$Script:Results += [PSCustomObject]@{ Test = $Name; Status = $status; Detail = $Detail }
|
||||
}
|
||||
|
||||
function Take-Screenshot {
|
||||
param([string]$Name)
|
||||
if (-not (Test-Path $ScreenshotDir)) {
|
||||
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
|
||||
}
|
||||
$outFile = Join-Path $ScreenshotDir "$Name.png"
|
||||
$nircmd = Get-Command nircmd -ErrorAction SilentlyContinue
|
||||
if ($nircmd) {
|
||||
Start-Process -FilePath $nircmd.Source -ArgumentList "savescreenshot", $outFile -NoNewWindow -Wait
|
||||
return $outFile
|
||||
}
|
||||
# Fallback: .NET screenshot
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
|
||||
$bitmap.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
return $outFile
|
||||
}
|
||||
|
||||
# ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
$AppName = "Android File Handler"
|
||||
$ExeName = "android-file-handler-windows.exe"
|
||||
$DefaultInstDir = Join-Path $env:ProgramFiles $AppName
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=============================================" -ForegroundColor Cyan
|
||||
Write-Host " Android File Handler - Vagrant Test Suite " -ForegroundColor Cyan
|
||||
Write-Host "=============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $ScreenshotDir)) {
|
||||
New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Build installer if not already present ──────────────────────────────────
|
||||
# When run directly inside the VM (not via run_vagrant_tests.ps1), the build
|
||||
# dir may not exist yet. Since the SMB mount is disabled by default, this
|
||||
# fallback requires manual transfer of assets to C:\vagrant_project.
|
||||
|
||||
if (-not (Test-Path $InstallerPath)) {
|
||||
$projectRoot = "C:\vagrant_project"
|
||||
$sourceExe = "$projectRoot\dist\android-file-handler-windows.exe"
|
||||
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
$buildRoot = "C:\vagrant_build"
|
||||
|
||||
if (-not (Test-Path $sourceExe)) {
|
||||
Write-Host "`nFATAL: Installer not found and cannot build - PyInstaller exe missing." -ForegroundColor Red
|
||||
Write-Host " Run PyInstaller on the host first, then use the orchestrator:" -ForegroundColor Yellow
|
||||
Write-Host " ..\scripts\windows\run_vagrant_tests.ps1"
|
||||
Write-Host " (Or manually transfer files to C:\vagrant_project inside the VM)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $iscc)) {
|
||||
Write-Host "`nFATAL: Inno Setup not found at $iscc." -ForegroundColor Red
|
||||
Write-Host " Re-provision the VM: vagrant provision" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "`n--- Building installer inside the VM (Inno Setup 6.7.1) ---" -ForegroundColor Yellow
|
||||
|
||||
@("$buildRoot\dist", "$buildRoot\scripts\windows", "$buildRoot\icon_media") |
|
||||
ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null }
|
||||
|
||||
Copy-Item $sourceExe "$buildRoot\dist\" -Force
|
||||
Copy-Item "$projectRoot\scripts\windows\android-file-handler-setup.iss" "$buildRoot\scripts\windows\" -Force
|
||||
Copy-Item "$projectRoot\LICENSE.txt" "$buildRoot\" -Force
|
||||
Copy-Item "$projectRoot\icon_media\robot_files_256.ico" "$buildRoot\icon_media\" -Force
|
||||
|
||||
$isccArgs = @("$buildRoot\scripts\windows\android-file-handler-setup.iss")
|
||||
if ($AppVersion) { $isccArgs += "/DMyAppVersion=$AppVersion" }
|
||||
|
||||
& $iscc @isccArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "FATAL: Inno Setup failed (exit $LASTEXITCODE)." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Installer built: $InstallerPath" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ── Test 1: Installer exists ────────────────────────────────────────────────
|
||||
|
||||
$installerExists = Test-Path $InstallerPath
|
||||
Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath
|
||||
|
||||
if (-not $installerExists) {
|
||||
throw "Installer not found at $InstallerPath."
|
||||
}
|
||||
|
||||
# ── Test 2: Silent install ──────────────────────────────────────────────────
|
||||
|
||||
Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow
|
||||
$installProc = Start-Process -FilePath $InstallerPath `
|
||||
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" `
|
||||
-Wait -PassThru
|
||||
|
||||
$installOk = $installProc.ExitCode -eq 0
|
||||
Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)"
|
||||
|
||||
# ── Test 3: Install directory created ────────────────────────────────────────
|
||||
|
||||
$dirExists = Test-Path $DefaultInstDir
|
||||
Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir
|
||||
|
||||
# ── Test 4: Executable present ──────────────────────────────────────────────
|
||||
|
||||
$exePath = Join-Path $DefaultInstDir $ExeName
|
||||
$exeExists = Test-Path $exePath
|
||||
Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath
|
||||
|
||||
# ── Test 5: Start Menu shortcut ─────────────────────────────────────────────
|
||||
|
||||
$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName"
|
||||
$shortcutPath = Join-Path $startMenuDir "$AppName.lnk"
|
||||
$shortcutExists = Test-Path $shortcutPath
|
||||
Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath
|
||||
|
||||
# ── Test 6: Registry uninstall entry ────────────────────────────────────────
|
||||
|
||||
# Check both the native (64-bit) and WoW6432Node (32-bit) uninstall hives.
|
||||
# WinRM sessions may run 32-bit PowerShell, which redirects HKLM:\SOFTWARE to
|
||||
# WoW6432Node, missing the 64-bit installer's entry if we only check one path.
|
||||
# try/catch in the Where-Object handles the StrictMode "property not found" throw
|
||||
# that neither -ErrorAction nor PSObject.Properties can fully avoid.
|
||||
$uninstallPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
)
|
||||
$registryEntry = $uninstallPaths | ForEach-Object {
|
||||
Get-ChildItem $_ -ErrorAction SilentlyContinue
|
||||
} | Where-Object {
|
||||
# Match exact name OR "AppName version X.Y.Z" (Inno Setup default when
|
||||
# AppVerName is not set). The .iss now sets AppVerName={#MyAppName} so
|
||||
# exact match is expected; the -like guard covers older installer builds.
|
||||
try {
|
||||
$dn = (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName
|
||||
$dn -eq $AppName -or $dn -like "$AppName *"
|
||||
} catch { $false }
|
||||
}
|
||||
$registryOk = $null -ne $registryEntry
|
||||
|
||||
if (-not $registryOk) {
|
||||
# Diagnostic: show non-Microsoft entries so the failure can be investigated.
|
||||
Write-Host " [Diag] Entries found in Uninstall hives:" -ForegroundColor DarkGray
|
||||
$uninstallPaths | ForEach-Object {
|
||||
Get-ChildItem $_ -ErrorAction SilentlyContinue
|
||||
} | ForEach-Object {
|
||||
$dn = try { (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName } catch { $null }
|
||||
if ($dn -and $dn -notmatch '^(Microsoft|Windows|KB\d)') {
|
||||
Write-Host " '$dn'" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk
|
||||
|
||||
# ── Test 7: Launch application & capture GUI ─────────────────────────────────
|
||||
|
||||
if ($exeExists) {
|
||||
Write-Host "`n--- Launching application ---" -ForegroundColor Yellow
|
||||
|
||||
$appProc = Start-Process -FilePath $exePath -PassThru
|
||||
Start-Sleep -Seconds 8
|
||||
|
||||
# Check if process is still running (GUI app should stay alive)
|
||||
$appRunning = -not $appProc.HasExited
|
||||
Write-TestResult -Name "Application launches and stays running" -Pass $appRunning
|
||||
|
||||
# Look for the window by title.
|
||||
# WinRM sessions run in a non-interactive window station; the GUI process
|
||||
# may have no accessible HWND from this session even when fully running.
|
||||
# Try .NET MainWindowTitle first, then Win32 FindWindow as a fallback.
|
||||
# If both come up empty but the process is still alive, count it as a pass
|
||||
# with a note — the "stays running" check above already validates the install.
|
||||
$windowFound = $false
|
||||
$windowNote = ""
|
||||
$hwnd = [IntPtr]::Zero
|
||||
|
||||
$appProc.Refresh()
|
||||
if ($appProc.MainWindowTitle -eq $AppName) {
|
||||
$windowFound = $true
|
||||
$windowNote = "via .NET MainWindowTitle"
|
||||
} else {
|
||||
try {
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class Win32 {
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
|
||||
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
|
||||
}
|
||||
"@ -ErrorAction SilentlyContinue
|
||||
$hwnd = [Win32]::FindWindow($null, $AppName)
|
||||
if ($hwnd -ne [IntPtr]::Zero) {
|
||||
$windowFound = $true
|
||||
$windowNote = "HWND: $hwnd"
|
||||
}
|
||||
} catch {
|
||||
Write-Host " Warning: Could not check for window title via Win32 API." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if (-not $windowFound -and $appRunning) {
|
||||
# Process is alive; window just not reachable from the WinRM session.
|
||||
$windowFound = $true
|
||||
$windowNote = "process alive - window not accessible from WinRM session"
|
||||
}
|
||||
}
|
||||
Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail $windowNote
|
||||
|
||||
# Take screenshot of the running application
|
||||
try {
|
||||
Start-Sleep -Seconds 2
|
||||
$screenshotFile = Take-Screenshot -Name "app_running"
|
||||
Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray
|
||||
} catch {
|
||||
Write-Host " Warning: Could not capture screenshot: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Gracefully close, then ensure the entire process tree is dead before
|
||||
# the uninstaller runs. PyInstaller onefile spawns a child process that
|
||||
# continues after the stub exits; taskkill /F /T kills both. The extra
|
||||
# sleep lets the OS release executable file handles so the uninstaller
|
||||
# can delete the .exe.
|
||||
if (-not $appProc.HasExited) {
|
||||
$appProc.CloseMainWindow() | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExeName)
|
||||
taskkill /F /T /IM "$procName.exe" 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3 # Wait for OS to release file handles
|
||||
}
|
||||
else {
|
||||
Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped - exe not found"
|
||||
Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped - exe not found"
|
||||
}
|
||||
|
||||
# ── Test 8: Silent uninstall ────────────────────────────────────────────────
|
||||
|
||||
Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow
|
||||
$uninstallerPath = Join-Path $DefaultInstDir "unins000.exe"
|
||||
if (Test-Path $uninstallerPath) {
|
||||
$uninstProc = Start-Process -FilePath $uninstallerPath `
|
||||
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" `
|
||||
-Wait -PassThru
|
||||
$uninstallOk = $uninstProc.ExitCode -eq 0
|
||||
Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)"
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
# Assert the executable is gone — not the directory. The app may have
|
||||
# created platform-tools/, a license file, or other runtime files inside
|
||||
# the install dir during Test 7; Inno Setup only tracks files it installed
|
||||
# and will not remove a non-empty directory.
|
||||
$exeGone = -not (Test-Path $exePath)
|
||||
Write-TestResult -Name "Executable removed after uninstall" -Pass $exeGone -Detail $exePath
|
||||
}
|
||||
else {
|
||||
Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found"
|
||||
Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped"
|
||||
}
|
||||
|
||||
} catch {
|
||||
# Catch unexpected terminating exceptions so the finally block can still save
|
||||
# results and so the PSSession is not broken by an unhandled remote exception.
|
||||
$errMsg = "$_"
|
||||
Write-Host "`nFATAL: Unhandled exception in test suite: $errMsg" -ForegroundColor Red
|
||||
Write-TestResult -Name "Script execution (fatal error)" -Pass $false -Detail $errMsg
|
||||
}
|
||||
finally {
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=============================================" -ForegroundColor Cyan
|
||||
Write-Host " Test Summary" -ForegroundColor Cyan
|
||||
Write-Host "=============================================" -ForegroundColor Cyan
|
||||
Write-Host " Passed : $Script:Passed" -ForegroundColor Green
|
||||
Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" })
|
||||
Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
$Script:Results | Format-Table -AutoSize
|
||||
|
||||
# Export results as CSV for retrieval
|
||||
if ($Script:Results.Count -gt 0) {
|
||||
$csvPath = Join-Path $ScreenshotDir "test_results.csv"
|
||||
$Script:Results | Export-Csv -Path $csvPath -NoTypeInformation
|
||||
Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if ($error.Count -gt 0) {
|
||||
Write-Host "`nScript encountered errors during execution:" -ForegroundColor Red
|
||||
$error[0] | Format-List -Force
|
||||
}
|
||||
}
|
||||
|
||||
exit $(if ($Script:Failed -gt 0) { 1 } else { 0 })
|
||||
Reference in New Issue
Block a user