Add spec files #5

Merged
JMR-dev merged 8 commits from add-spec-files into main 2025-08-26 23:37:09 +00:00
15 changed files with 611 additions and 301 deletions
+7 -127
View File
@@ -53,7 +53,8 @@ jobs:
- name: Build Windows executable
run: |
poetry run pyinstaller --onefile --windowed --icon=icon_media/robot_files_256.ico --name android-file-handler src/main.py
# Use the Windows spec file so packaging is consistent and reproducible
poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
@@ -101,7 +102,8 @@ jobs:
export PATH="$HOME/.local/bin:$PATH"
poetry env use python3.12 || true
poetry install
poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py
# Use the Linux spec file for reproducible builds
poetry run pyinstaller scripts/spec_scripts/android-file-handler-linux.spec
- name: Package .deb (fpm)
run: |
@@ -159,7 +161,8 @@ jobs:
run: |
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
poetry install
poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py
# Use the Linux spec file for reproducible builds on Arch
poetry run pyinstaller scripts/spec_scripts/android-file-handler-linux.spec
- name: Package pacman (fpm)
run: |
@@ -216,127 +219,4 @@ jobs:
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-rocky:
# runs-on: ubuntu-latest
# container:
# image: rockylinux:9
# steps:
# - name: Robust dnf setup and install build deps (retry-clean-refresh)
# run: |
# set -euo pipefail
# retry() {
# local max_tries=5
# local sleep_base=3
# local n=1
# until "$@"; do
# if [ $n -ge $max_tries ]; then
# echo "Command failed after $n attempts: $*"
# return 1
# fi
# echo "Command failed: $*. Retrying ($n/$max_tries) after $((sleep_base * n))s..."
# sleep $((sleep_base * n))
# n=$((n + 1))
# done
# return 0
# }
# # Ensure plugin and enable CRB
# retry dnf -y install dnf-plugins-core
# retry dnf config-manager --set-enabled crb || true
# # Clean caches and refresh metadata
# retry dnf -y clean all
# rm -rf /var/cache/dnf || true
# retry dnf -y makecache --refresh
# # Install build-time deps including ruby so gem stdlib is present
# retry dnf -y install -y \
# git gcc make openssl-devel bzip2-devel libffi-devel zlib-devel xz-devel \
# readline-devel sqlite-devel tk-devel gdbm-devel libuuid-devel patch \
# ruby ruby-devel rubygems rpm-build tar ca-certificates wget
# - name: Clean up pyenv
# run: |
# echo "Removing existing pyenv installations"
# rm -rf "${GITHUB_WORKSPACE}/.pyenv"
# rm -rf "${HOME}/.pyenv"
# - name: Tarball-based pyenv + python-build (non-interactive)
# env:
# PYENV_ROOT: ${{ github.workspace }}/.pyenv # Set this BEFORE running installer
# run: |
# set -euo pipefail
# echo "PYENV_ROOT is set to: $PYENV_ROOT"
# mkdir -p "$PYENV_ROOT"
# echo "Downloading pyenv installer and running it"
# wget -qO- https://pyenv.run | bash
# # Initialize pyenv environment for this job
# export PATH="$PYENV_ROOT/bin:$PATH"
# eval "$($PYENV_ROOT/bin/pyenv init -)"
# # Persist environment variables for subsequent steps
# echo "PYENV_ROOT=$PYENV_ROOT" >> $GITHUB_ENV
# echo "PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH" >> $GITHUB_ENV
# echo "pyenv version:"
# "$PYENV_ROOT/bin/pyenv" --version || true
# - name: Build Python with pyenv (3.12.11) and set global
# env:
# PYENV_ROOT: ${{ github.workspace }}/.pyenv
# run: |
# set -euo pipefail
# export PYENV_ROOT="${PYENV_ROOT}"
# export PATH="$PYENV_ROOT/bin:$PATH"
# eval "$($PYENV_ROOT/bin/pyenv init -)"
# # Install python (idempotent)
# $PYENV_ROOT/bin/pyenv install -s 3.12.11
# $PYENV_ROOT/bin/pyenv global 3.12.11
# python --version
# pip --version
# - name: Install Poetry
# uses: snok/install-poetry@v1
# with:
# version: latest
# virtualenvs-create: true
# virtualenvs-in-project: true
# - name: Configure Poetry
# run: |
# echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV
# export PATH="$HOME/.local/bin:$PATH"
# poetry config virtualenvs.create true
# poetry config virtualenvs.in-project true
# - name: Install dependencies & build executable
# run: |
# set -euo pipefail
# export PATH="$HOME/.local/bin:$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH"
# poetry env use 3.12.11 || true
# poetry install
# poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py
# - name: Install fpm and package .rpm
# run: |
# set -euo pipefail
# # install pinned fpm and ensure binary is in system bindir
# gem install --no-document -v "${FPM_VERSION}" fpm --bindir /usr/local/bin
# echo "/usr/local/bin" >> $GITHUB_PATH
# # Build rpm with explicit filename
# VERSION="$(poetry version -s)"
# mkdir -p dist
# chmod +x dist/android-file-handler
# fpm -s dir -t rpm -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/local/bin -p "dist/android-file-handler-${VERSION}-1.x86_64.rpm" dist/android-file-handler
# - name: Upload Rocky RPM
# uses: actions/upload-artifact@v4
# with:
# name: rocky-package
# path: dist/*.rpm
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -7,6 +7,8 @@ build
.actrc
bin/
test-act.yml
pkg_dist
# Python cache files
__pycache__/
*.py[cod]
+1 -1
View File
@@ -18,7 +18,7 @@ A simple Windows GUI tool to pull folders from an Android device using ADB (Andr
- [Poetry](https://python-poetry.org/) for dependency management
- (Optional) pyenv for Python version managment
## Installation
## Installation (Developers)
1. Clone this repository:
+89
View File
@@ -0,0 +1,89 @@
# Arch Linux installation instructions
## Arch Linux installation
These instructions describe how Arch users can install the Linux build produced by the project's `simple-release.yml` workflow. The workflow produces an Arch package named similar to:
```bash
android-file-handler-<VERSION>-1-x86_64.pkg.tar.zst
```
Find that file on the project's GitHub Releases page for the matching version and download it.
### System install (recommended)
Use pacman to install the downloaded package so package scripts run and files are placed in standard system locations:
```bash
# from the directory with the downloaded package
sudo pacman -U ./android-file-handler-1.2.3-1-x86_64.pkg.tar.zst
```
Replace the filename with the actual release artifact you downloaded. After installation you should have:
- Binary: e.g. `/usr/local/bin/android-file-handler`
- Desktop entry: `/usr/share/applications/android-file-handler.desktop`
- Icons: under `/usr/share/icons/hicolor/...`
Launch the app from your application menu (search "Android File Handler") or from a terminal:
```bash
android-file-handler
```
### Non-root / manual install
If you cannot or do not want to install the package system-wide, you can extract and install files into your home directory manually:
1. Extract the package to a temporary folder:
```bash
mkdir -p /tmp/afh_pkg
tar -I zstd -xvf android-file-handler-1.2.3-1-x86_64.pkg.tar.zst -C /tmp/afh_pkg
```
2. Copy the executable and desktop/icon files to your local directories:
```bash
mkdir -p ~/.local/bin ~/.local/share/applications ~/.local/share/icons/hicolor/256x256/apps
cp /tmp/afh_pkg/usr/local/bin/android-file-handler ~/.local/bin/
chmod +x ~/.local/bin/android-file-handler
# Copy desktop entry and icon (paths inside the package may vary)
cp /tmp/afh_pkg/usr/share/applications/android-file-handler.desktop ~/.local/share/applications/
cp /tmp/afh_pkg/usr/share/icons/hicolor/256x256/apps/android-file-handler.png ~/.local/share/icons/hicolor/256x256/apps/
```
3. (Optional) Update desktop and icon caches so the menu picks up the new entry immediately:
```bash
update-desktop-database ~/.local/share/applications || true
gtk-update-icon-cache -t -f ~/.local/share/icons/hicolor || true
```
Now run the app from the menu or with `~/.local/bin/android-file-handler`.
### Verify package contents
To inspect the package contents without installing:
```bash
# list files in the package
pacman -Qlp android-file-handler-1.2.3-1-x86_64.pkg.tar.zst
```
To query package info (after installation):
```bash
pacman -Qi android-file-handler
```
### Troubleshooting
- If the app does not appear in your menu, try logging out/in or run the cache update commands above.
- If icons are missing, ensure the `icons/hicolor/*/apps/android-file-handler.png` entries were copied to the correct theme directories.
- If you manually copied files, ensure `~/.local/bin` is on your PATH; add it to `~/.profile` if necessary:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
Generated
+5 -5
View File
@@ -533,14 +533,14 @@ files = [
[[package]]
name = "platformdirs"
version = "4.3.8"
version = "4.4.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"},
{file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"},
{file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
{file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
]
[package.extras]
@@ -909,4 +909,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.1"
python-versions = "<3.13,>=3.12"
content-hash = "dfb6152cb0fb34dcda59387fbaef4f445b0f38437bd7bdfe25b38b62b14d527e"
content-hash = "2e5b4bc609ab9efe7fe2f83c7c475b1189133759463452d0ed664870cea7ed08"
+2 -1
View File
@@ -8,11 +8,12 @@ authors = [
license = { text = "MIT" }
readme = "README.md"
requires-python = "<3.13,>=3.12"
dependencies = ["requests>=2.32.4,<3.0.0"]
dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"]
[tool.poetry.dependencies]
python = "<3.13,>=3.12"
requests = ">=2.32.4,<3.0.0"
platformdirs = ">4.0.0,<5.0.0"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
poetry lock
poetry install
poetry run pyinstaller --onefile src/main.py --distpath dist --name android-file-handler-windows
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -euo pipefail
poetry lock
poetry install
# Build using the Linux spec so the build is reproducible and uses the project spec
poetry run pyinstaller /home/jasonross/workspace/android_file_handler_adb/scripts/spec_scripts/android-file-handler-linux.spec --distpath dist
# Package into a Debian .deb using fpm
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
VERSION="$(poetry version -s)"
mkdir -p dist
chmod +x dist/android-file-handler
# explicit filename into dist/ (Debian .deb)
# change directory into dist and package the binary named 'android-file-handler'
# this ensures the package installs /usr/local/bin/android-file-handler (no extra dist/ prefix)
# Debian
# Prepare packaging layout in a separate directory to avoid clobbering build artifacts
PKG_DIR="pkg_dist"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR/usr/local/bin" \
"$PKG_DIR/usr/share/applications" \
"$PKG_DIR/usr/share/icons/hicolor/256x256/apps"
# Copy built binary
cp dist/android-file-handler "$PKG_DIR/usr/local/bin/android-file-handler"
chmod 0755 "$PKG_DIR/usr/local/bin/android-file-handler"
# ...existing code...
# Copy icon if present in repo (fallback optional) — do this before creating the .desktop
ICON_SRC="icon_media/robot_files_256.png"
ICON_DST="$PKG_DIR/usr/share/icons/hicolor/256x256/apps/android-file-handler.png"
ICON_INCLUDED=false
if [ -f "$ICON_SRC" ]; then
mkdir -p "$(dirname "$ICON_DST")"
cp "$ICON_SRC" "$ICON_DST"
chmod 644 "$ICON_DST"
ICON_INCLUDED=true
else
echo "Warning: icon not found at $ICON_SRC; packaging without icon"
fi
# Create .desktop file (icon name matches installed icon)
cat > "$PKG_DIR/usr/share/applications/android-file-handler.desktop" <<'EOF'
[Desktop Entry]
Type=Application
Name=Android File Handler
Comment=Manage Android device files via ADB
Exec=/usr/local/bin/android-file-handler %U
Icon=android-file-handler
Terminal=false
Categories=Utility;Development;
StartupNotify=true
EOF
chmod 644 "$PKG_DIR/usr/share/applications/android-file-handler.desktop"
# Build the Debian package and include postinst script
PKG_ITEMS=( "usr/local/bin/android-file-handler" "usr/share/applications/android-file-handler.desktop" )
if [ "$ICON_INCLUDED" = true ]; then
PKG_ITEMS+=( "usr/share/icons/hicolor/256x256/apps/android-file-handler.png" )
fi
# Debug listing
echo "Packaging the following items (relative to $PKG_DIR):"
printf ' - %s\n' "${PKG_ITEMS[@]}"
for p in "${PKG_ITEMS[@]}"; do ls -la "$PKG_DIR/$p" || echo " (missing) $PKG_DIR/$p"; done
fpm -s dir -t deb -n android-file-handler -v "$VERSION" \
--architecture amd64 --deb-user root --deb-group root \
--after-install scripts/linux_postinst.sh \
-p "dist/android-file-handler_${VERSION}_amd64.deb" -C "$PKG_DIR" "${PKG_ITEMS[@]}"
echo "Package created at dist/android-file-handler_${VERSION}_amd64.deb"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -e
# Post-installation script for android-file-handler
# - update desktop database if available
# - update icon cache if available
# - ensure binary is executable
# Update desktop database if the utility exists
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications || true
fi
# Update GTK icon cache for hicolor theme if utility exists
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
if [ -d /usr/share/icons/hicolor ]; then
gtk-update-icon-cache -t -f /usr/share/icons/hicolor || true
fi
fi
# Ensure installed binary is executable
if [ -f /usr/local/bin/android-file-handler ]; then
chmod 0755 /usr/local/bin/android-file-handler || true
fi
exit 0
@@ -0,0 +1,49 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['../../src/main.py'],
pathex=['src'],
binaries=[],
datas=[
('../../src/gui', 'gui'),
('../../scripts/linux_postinst.sh', 'scripts'),
],
hiddenimports=[
'gui',
'gui.file_browser',
'gui.license_agreement',
'gui.main_window',
'gui.progress_handler',
'adb_manager'
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='android-file-handler',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
@@ -1,15 +1,18 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['src/main.py'],
pathex=[],
pathex=['src'],
binaries=[],
datas=[],
hiddenimports=[],
datas=[('src/gui', 'gui'), ('../scripts/windows/first_run_install.ps1', 'scripts/windows')],
hiddenimports=[
'gui',
'gui.file_browser',
'gui.license_agreement',
'gui.main_window',
'gui.progress_handler'
'adb_manager'
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -20,7 +23,6 @@ a = Analysis(
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
@@ -35,10 +37,11 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
icon='icon_media/robot_files_256.ico',
)
+51
View File
@@ -0,0 +1,51 @@
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
+197 -126
View File
@@ -14,6 +14,9 @@ import glob
import time
import re
from typing import Optional, Tuple, Callable
import tempfile
from typing import Optional
import hashlib
def get_executable_directory() -> str:
"""Get the directory containing the executable or script."""
@@ -23,6 +26,140 @@ def get_executable_directory() -> str:
else:
# Running as script - use the script's directory
return os.path.dirname(os.path.abspath(__file__))
def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> str:
"""Ensure platform-tools installed in a per-user data dir and return adb path.
Behavior:
- Uses platformdirs.user_data_dir('android-file-handler') if available,
else falls back to ~/.local/share/android-file-handler (POSIX) or
%LOCALAPPDATA% on Windows via expanduser.
- Installs into <data_dir>/platform-tools/<version>/ and creates/update
a symlink <data_dir>/platform-tools/current -> <version>.
- Downloads into a temp dir and moves atomically to avoid partial installs.
- Sets executable permissions on adb binary.
- Returns absolute path to adb binary (no PATH modification required).
"""
try:
from platformdirs import user_data_dir # type: ignore
except Exception:
user_data_dir = None
# Determine base data dir
if user_data_dir:
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")
os.makedirs(data_root, exist_ok=True)
target_version = version_tag or "latest"
target_dir = os.path.join(data_root, target_version)
current_link = os.path.join(data_root, "current")
# If current symlink exists and points to a valid adb, return it
if os.path.islink(current_link):
try:
resolved = os.path.realpath(current_link)
adb_name = "adb.exe" if sys.platform.startswith("win") else "adb"
candidate = os.path.join(resolved, adb_name)
if os.path.isfile(candidate):
return candidate
except Exception:
pass
# If requested version already installed, point current there
if os.path.isdir(target_dir) and os.path.isfile(os.path.join(target_dir, "adb" if not sys.platform.startswith("win") else "adb.exe")):
# update symlink atomically
if os.path.islink(current_link) or os.path.exists(current_link):
try:
os.remove(current_link)
except Exception:
pass
try:
os.symlink(target_dir, current_link)
except Exception:
# best-effort, ignore if unable to create symlink
pass
return os.path.join(target_dir, "adb.exe" if sys.platform.startswith("win") else "adb")
# Download into temp location and extract
tmp_dir = tempfile.mkdtemp(prefix="platform-tools-")
try:
# choose URL
if sys.platform.startswith("linux"):
url = ADB_LINUX_ZIP_URL
elif sys.platform.startswith("win"):
url = ADB_WIN_ZIP_URL
else:
raise RuntimeError("Unsupported platform for platform-tools download")
# download in streaming fashion to avoid memory pressure
resp = requests.get(url, stream=True, timeout=30)
resp.raise_for_status()
zip_path = os.path.join(tmp_dir, "platform-tools.zip")
with open(zip_path, "wb") as fh:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
fh.write(chunk)
# extract
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmp_dir)
# the zip contains a top-level platform-tools directory; move that into target_dir
extracted_dir = os.path.join(tmp_dir, "platform-tools")
if not os.path.isdir(extracted_dir):
# 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"):
extracted_dir = candidate
break
if not os.path.isdir(extracted_dir):
raise RuntimeError("Platform-tools not found in archive")
# Atomic install: move extracted_dir -> target_dir (remove existing backup first)
if os.path.isdir(target_dir):
backup = f"{target_dir}.bak"
shutil.rmtree(backup, ignore_errors=True)
shutil.move(target_dir, backup)
shutil.move(extracted_dir, target_dir)
# Ensure adb executable perms on POSIX
adb_name = "adb.exe" if sys.platform.startswith("win") else "adb"
adb_path = os.path.join(target_dir, adb_name)
if os.path.isfile(adb_path) and os.name == "posix":
os.chmod(adb_path, 0o755)
# Atomically update 'current' symlink
tmp_link = f"{current_link}.tmp"
try:
if os.path.exists(tmp_link):
os.remove(tmp_link)
os.symlink(target_dir, tmp_link)
os.replace(tmp_link, current_link)
except OSError:
# fallback: remove and recreate
try:
if os.path.exists(current_link):
os.remove(current_link)
os.symlink(target_dir, current_link)
except Exception:
pass
return adb_path
finally:
# Clean temp dir
try:
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
except Exception:
pass
def get_platform_tools_directory() -> str:
"""Get platform-tools directory."""
@@ -60,7 +197,6 @@ ADB_WIN_ZIP_URL = (
ADB_LINUX_ZIP_URL = (
"https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
)
LOCAL_ADB_FOLDER = get_platform_tools_directory()
OS_TYPE = sys.platform
# Determine ADB binary name based on platform
@@ -71,7 +207,28 @@ elif OS_TYPE.startswith("win"):
else:
ADB_BINARY_NAME = "adb"
ADB_BINARY_PATH = os.path.join(LOCAL_ADB_FOLDER, ADB_BINARY_NAME)
# ADB binary path is resolved at runtime via get_adb_binary_path() to avoid
# duplicate logic and to centralize platform-tools installation behavior.
def get_adb_binary_path() -> str:
"""Return the path to the adb binary, installing platform-tools if needed.
This central helper ensures a consistent location across the codebase.
"""
adb_name = "adb.exe" if sys.platform.startswith("win") else "adb"
try:
# If platform-tools are installed in the user data dir, prefer that
adb_path = ensure_platform_tools_in_user_dir()
if adb_path and os.path.isfile(adb_path):
return adb_path
except Exception:
pass
# Fallback: look for an executable next to the project or installed path
local_folder = get_platform_tools_directory()
candidate = os.path.join(local_folder, adb_name)
return candidate
class ADBManager:
@@ -103,60 +260,39 @@ class ADBManager:
def check_local_disk_space(self) -> bool:
"""Check if there's enough disk space for ADB download."""
try:
free_space = shutil.disk_usage(LOCAL_ADB_FOLDER)[2]
free_space = shutil.disk_usage(get_platform_tools_directory())[2]
if free_space < 50 * 1024 * 1024: # 50MB minimum
raise Exception("Insufficient disk space")
return True
except OSError:
# Create directory if it doesn't exist
os.makedirs(LOCAL_ADB_FOLDER, exist_ok=True)
os.makedirs(get_platform_tools_directory(), exist_ok=True)
return True
def download_and_extract_adb(self) -> bool:
"""Download and extract ADB tools if not present."""
if os.path.isfile(ADB_BINARY_PATH):
return True
# Use the centralized installer which will return the adb path (and
# perform a download if needed). If it returns a valid path, report
# success; otherwise return False.
try:
# Determine the correct URL based on platform
if OS_TYPE.startswith("linux"):
adb_zip_url = ADB_LINUX_ZIP_URL
elif OS_TYPE.startswith("win"):
adb_zip_url = ADB_WIN_ZIP_URL
else:
print("Unsupported platform")
return False
if not self.check_local_disk_space():
return False
self._update_status("Downloading platform-tools (ADB)...")
response = requests.get(adb_zip_url, stream=True, timeout=30)
response.raise_for_status()
binary_archive = zipfile.ZipFile(io.BytesIO(response.content))
# Extract to parent directory since the zip contains platform-tools/ folder
extract_to = os.path.dirname(LOCAL_ADB_FOLDER)
os.makedirs(extract_to, exist_ok=True)
binary_archive.extractall(extract_to)
if OS_TYPE.startswith("linux"):
if os.path.exists(ADB_BINARY_PATH):
os.chmod(ADB_BINARY_PATH, 0o755)
else:
raise Exception("ADB binary not found after extraction")
self._update_status("Downloaded and extracted platform-tools.")
return True
adb_path = ensure_platform_tools_in_user_dir()
if adb_path and os.path.isfile(adb_path):
# Ensure executable permissions on POSIX
if os.name == "posix":
try:
os.chmod(adb_path, 0o755)
except Exception:
pass
self._update_status("ADB available at: " + adb_path)
return True
return False
except Exception as e:
print(f"Failed to download ADB: {e}")
self._update_status(f"Failed to ensure platform-tools: {e}")
return False
def run_adb_command(self, args: list, capture_output: bool = True):
"""Run an ADB command and return output."""
cmd = [ADB_BINARY_PATH] + args
cmd = [get_adb_binary_path()] + args
try:
if capture_output:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
@@ -223,35 +359,29 @@ class ADBManager:
def pull_folder(self, remote_path: str, local_path: str) -> bool:
"""Pull files from Android device to local machine."""
# Normalize paths for better compatibility
# Normalize paths and prepare
local_path = os.path.normpath(local_path)
remote_path = remote_path.strip()
# Ensure local directory exists
try:
os.makedirs(local_path, exist_ok=True)
except Exception as e:
self._update_status(f"Failed to create local directory: {e}")
return False
# For Windows root drives, ensure proper formatting
# Warn if writing to root drive on Windows
if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status(
"Warning: Transferring to root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path]
# Debug output for troubleshooting
cmd = [get_adb_binary_path(), "pull", remote_path, local_path]
self._update_status(f"Command: adb pull '{remote_path}' '{local_path}'")
try:
# Start with initial progress
self._update_progress(0)
self._update_status("Starting transfer...")
@@ -276,45 +406,29 @@ class ADBManager:
pct = self.parse_progress(line)
if pct is not None:
# Use explicit progress when available
self._update_progress(pct)
last_progress = pct
last_update_time = current_time
else:
# Improved progress estimation for large transfers
elapsed_time = current_time - start_time
time_since_last_update = current_time - last_update_time
# Calculate progress based on multiple factors
should_update = False
new_progress = last_progress
# Time-based progress (update every 2 seconds)
if time_since_last_update >= 2.0 and last_progress < 95:
# Estimate progress based on activity and time
if line_count > 100:
# For large transfers, use a logarithmic approach
activity_factor = min(
line_count / 1000, 50
) # Max 50% from activity
time_factor = min(
elapsed_time / 60, 40
) # Max 40% from time (assumes 1-2 min transfers)
activity_factor = min(line_count / 1000, 50)
time_factor = min(elapsed_time / 60, 40)
new_progress = min(activity_factor + time_factor, 95)
else:
# For smaller transfers, use the original approach
new_progress = min(last_progress + 10, 95)
should_update = True
# Line-based progress (for very active transfers)
elif line_count % 50 == 0 and last_progress < 90:
# More conservative line-based updates
increment = max(1, min(5, 90 // (line_count // 50 + 1)))
new_progress = min(last_progress + increment, 90)
should_update = True
# Update progress if needed
if should_update and new_progress > last_progress:
self._update_progress(int(new_progress))
last_progress = new_progress
@@ -329,19 +443,17 @@ class ADBManager:
self.current_process = None
return True
else:
# Capture error output for better debugging
error_msg = f"Transfer failed with code {proc.returncode}"
if hasattr(proc, "stderr") and proc.stderr:
try:
stderr_output = proc.stderr.read()
if stderr_output:
error_msg += f". Error: {stderr_output}"
except:
except Exception:
pass
self._update_status(error_msg)
self.current_process = None
return False
except Exception as e:
self._update_status(f"Transfer error: {e}")
self.current_process = None
@@ -349,33 +461,25 @@ class ADBManager:
def push_folder(self, local_path: str, remote_path: str) -> bool:
"""Push files from local machine to Android device."""
# Normalize paths for better compatibility
local_path = os.path.normpath(local_path)
remote_path = remote_path.strip()
# Validate local path exists
if not os.path.exists(local_path):
self._update_status(f"Local path does not exist: {local_path}")
return False
# For Windows root drives, ensure proper formatting
if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status(
"Warning: Pushing from root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "push", local_path, remote_path]
# Debug output for troubleshooting
cmd = [get_adb_binary_path(), "push", local_path, remote_path]
self._update_status(f"Command: adb push '{local_path}' '{remote_path}'")
try:
# Start with initial progress
self._update_progress(0)
self._update_status("Starting transfer...")
@@ -403,46 +507,13 @@ class ADBManager:
pct = self.parse_progress(line)
if pct is not None:
# Use explicit progress when available
self._update_progress(pct)
last_progress = pct
last_update_time = current_time
else:
# Improved progress estimation for large transfers
elapsed_time = current_time - start_time
time_since_last_update = current_time - last_update_time
# Calculate progress based on multiple factors
should_update = False
new_progress = last_progress
# Time-based progress (update every 2 seconds)
if time_since_last_update >= 2.0 and last_progress < 95:
# Estimate progress based on activity and time
if line_count > 100:
# For large transfers, use a logarithmic approach
activity_factor = min(
line_count / 1000, 50
) # Max 50% from activity
time_factor = min(
elapsed_time / 60, 40
) # Max 40% from time (assumes 1-2 min transfers)
new_progress = min(activity_factor + time_factor, 95)
else:
# For smaller transfers, use the original approach
new_progress = min(last_progress + 10, 95)
should_update = True
# Line-based progress (for very active transfers)
elif line_count % 50 == 0 and last_progress < 90:
# More conservative line-based updates
increment = max(1, min(5, 90 // (line_count // 50 + 1)))
new_progress = min(last_progress + increment, 90)
should_update = True
# Update progress if needed
if should_update and new_progress > last_progress:
if elapsed_time >= 1.0 and last_progress < 90:
new_progress = min(last_progress + 20, 90)
self._update_progress(int(new_progress))
last_progress = new_progress
last_update_time = current_time
@@ -456,14 +527,13 @@ class ADBManager:
self.current_process = None
return True
else:
# Capture error output for better debugging
error_msg = f"Push failed with code {proc.returncode}"
if hasattr(proc, "stderr") and proc.stderr:
try:
stderr_output = proc.stderr.read()
if stderr_output:
error_msg += f". Error: {stderr_output}"
except:
except Exception:
pass
self._update_status(error_msg)
self.current_process = None
@@ -495,7 +565,7 @@ class ADBManager:
"Warning: Transferring to root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "pull", remote_file_path, local_file_path]
cmd = [get_adb_binary_path(), "pull", remote_file_path, local_file_path]
# Debug output for troubleshooting
self._update_status(f"Command: adb pull '{remote_file_path}' '{local_file_path}'")
@@ -589,7 +659,7 @@ class ADBManager:
"Warning: Pushing from root drive. Consider using a subfolder."
)
cmd = [ADB_BINARY_PATH, "push", local_file_path, remote_file_path]
cmd = [get_adb_binary_path(), "push", local_file_path, remote_file_path]
# Debug output for troubleshooting
self._update_status(f"Command: adb push '{local_file_path}' '{remote_file_path}'")
@@ -681,6 +751,8 @@ class ADBManager:
return False
return False
# (Method intentionally removed - use top-level ensure_platform_tools_in_user_dir)
class LinuxMTPManager:
"""Manages MTP operations on Linux systems."""
@@ -808,14 +880,13 @@ class LinuxMTPManager:
# Helper functions for standalone usage
def get_adb_binary_path() -> str:
"""Get the path to the ADB binary."""
return ADB_BINARY_PATH
def is_adb_available() -> bool:
"""Check if ADB binary is available."""
return os.path.isfile(ADB_BINARY_PATH)
"""Check if ADB binary is available using the centralized resolver."""
try:
adb_path = get_adb_binary_path()
return os.path.isfile(adb_path)
except Exception:
return False
def get_platform_type() -> str:
+91 -21
View File
@@ -7,42 +7,76 @@ import os
import sys
import tkinter as tk
from tkinter import messagebox, scrolledtext
import tempfile
import stat
import subprocess
import sys
import os
def get_license_file_path():
"""Get the path to the license agreement file."""
# Get the directory where the application is running from
if getattr(sys, 'frozen', False):
# Running as executable
app_dir = os.path.dirname(sys.executable)
def get_license_file_path() -> str:
"""Return the per-user license-agreed file path.
Uses platform-appropriate user config directory when possible:
- On Linux/macOS this will typically use ~/.config or ~/.android-file-handler fallback.
- On Windows it prefers %APPDATA% (via platformdirs) and falls back to ~.
"""
try:
from platformdirs import user_config_dir
except Exception:
user_config_dir = None
if getattr(sys, "frozen", False):
# Running as executable: prefer platform dirs
if user_config_dir:
config_dir = os.path.join(user_config_dir("android-file-handler"), "")
else:
config_dir = os.path.expanduser("~/.android-file-handler")
os.makedirs(config_dir, exist_ok=True)
return os.path.join(config_dir, "license_agreed.ini")
else:
# Running as script - use directory containing the main script
# Dev mode: store beside project tree
app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(app_dir, "license_agreed.ini")
return os.path.join(app_dir, "dev-mode_license_agreed.ini")
def check_license_agreement():
def check_license_agreement() -> bool:
"""Check if user has already agreed to the license."""
license_file = get_license_file_path()
try:
if os.path.exists(license_file):
with open(license_file, 'r') as f:
content = f.read().strip()
return content == "1"
with open(license_file, "r") as f:
return f.read().strip() == "1"
return False
except Exception:
return False
def save_license_agreement():
"""Save that the user has agreed to the license."""
def save_license_agreement() -> bool:
"""Persist that the user agreed to the license.
Writes atomically and sets secure file permissions on POSIX.
"""
license_file = get_license_file_path()
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(license_file), exist_ok=True)
with open(license_file, 'w') as f:
f.write("1")
parent_dir = os.path.dirname(license_file)
os.makedirs(parent_dir, exist_ok=True)
# Atomic write to temporary file then rename
fd, tmp_path = tempfile.mkstemp(dir=parent_dir)
try:
with os.fdopen(fd, "w") as fh:
fh.write("1")
# Set owner-only permissions on POSIX
if os.name == "posix":
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, license_file)
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
return True
except Exception:
return False
@@ -73,12 +107,48 @@ 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."""
def __init__(self, parent, on_agree_callback):
"""Initialize the license agreement frame.
Args:
parent: Parent widget
on_agree_callback: Function to call when user agrees to license
+3 -6
View File
@@ -4,13 +4,10 @@ Android File Handler - Main Entry Point
Simple entry point to launch the Android file transfer application.
"""
try:
# Try relative import first (when used as module)
from .gui.main_window import main
except ImportError:
# Fall back to direct import (when run directly)
from gui.main_window import main
from gui.main_window import main
from gui.license_agreement import run_windows_first_run_if_needed
if __name__ == "__main__":
run_windows_first_run_if_needed()
main()