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 - name: Build Windows executable
run: | 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 - name: Upload Windows artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -101,7 +102,8 @@ jobs:
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
poetry env use python3.12 || true poetry env use python3.12 || true
poetry install 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) - name: Package .deb (fpm)
run: | run: |
@@ -159,7 +161,8 @@ jobs:
run: | run: |
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
poetry install 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) - name: Package pacman (fpm)
run: | run: |
@@ -216,127 +219,4 @@ jobs:
draft: false draft: false
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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
+2
View File
@@ -7,6 +7,8 @@ build
.actrc .actrc
bin/ bin/
test-act.yml test-act.yml
pkg_dist
# Python cache files # Python cache files
__pycache__/ __pycache__/
*.py[cod] *.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 - [Poetry](https://python-poetry.org/) for dependency management
- (Optional) pyenv for Python version managment - (Optional) pyenv for Python version managment
## Installation ## Installation (Developers)
1. Clone this repository: 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]] [[package]]
name = "platformdirs" 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`." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"] groups = ["main", "dev"]
files = [ files = [
{file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
{file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
] ]
[package.extras] [package.extras]
@@ -909,4 +909,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "<3.13,>=3.12" python-versions = "<3.13,>=3.12"
content-hash = "dfb6152cb0fb34dcda59387fbaef4f445b0f38437bd7bdfe25b38b62b14d527e" content-hash = "2e5b4bc609ab9efe7fe2f83c7c475b1189133759463452d0ed664870cea7ed08"
+2 -1
View File
@@ -8,11 +8,12 @@ authors = [
license = { text = "MIT" } license = { text = "MIT" }
readme = "README.md" readme = "README.md"
requires-python = "<3.13,>=3.12" 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] [tool.poetry.dependencies]
python = "<3.13,>=3.12" python = "<3.13,>=3.12"
requests = ">=2.32.4,<3.0.0" requests = ">=2.32.4,<3.0.0"
platformdirs = ">4.0.0,<5.0.0"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] 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 -*- # -*- mode: python ; coding: utf-8 -*-
block_cipher = None block_cipher = None
a = Analysis( a = Analysis(
['src/main.py'], ['src/main.py'],
pathex=[], pathex=['src'],
binaries=[], binaries=[],
datas=[], datas=[('src/gui', 'gui'), ('../scripts/windows/first_run_install.ps1', 'scripts/windows')],
hiddenimports=[], hiddenimports=[
'gui',
'gui.file_browser',
'gui.license_agreement',
'gui.main_window',
'gui.progress_handler'
'adb_manager'
],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
@@ -20,7 +23,6 @@ a = Analysis(
noarchive=False, noarchive=False,
) )
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
@@ -35,10 +37,11 @@ exe = EXE(
upx=True, upx=True,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=True, console=False,
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=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 time
import re import re
from typing import Optional, Tuple, Callable from typing import Optional, Tuple, Callable
import tempfile
from typing import Optional
import hashlib
def get_executable_directory() -> str: def get_executable_directory() -> str:
"""Get the directory containing the executable or script.""" """Get the directory containing the executable or script."""
@@ -23,6 +26,140 @@ def get_executable_directory() -> str:
else: else:
# Running as script - use the script's directory # Running as script - use the script's directory
return os.path.dirname(os.path.abspath(__file__)) 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: def get_platform_tools_directory() -> str:
"""Get platform-tools directory.""" """Get platform-tools directory."""
@@ -60,7 +197,6 @@ ADB_WIN_ZIP_URL = (
ADB_LINUX_ZIP_URL = ( ADB_LINUX_ZIP_URL = (
"https://dl.google.com/android/repository/platform-tools-latest-linux.zip" "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
) )
LOCAL_ADB_FOLDER = get_platform_tools_directory()
OS_TYPE = sys.platform OS_TYPE = sys.platform
# Determine ADB binary name based on platform # Determine ADB binary name based on platform
@@ -71,7 +207,28 @@ elif OS_TYPE.startswith("win"):
else: else:
ADB_BINARY_NAME = "adb" 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: class ADBManager:
@@ -103,60 +260,39 @@ class ADBManager:
def check_local_disk_space(self) -> bool: def check_local_disk_space(self) -> bool:
"""Check if there's enough disk space for ADB download.""" """Check if there's enough disk space for ADB download."""
try: 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 if free_space < 50 * 1024 * 1024: # 50MB minimum
raise Exception("Insufficient disk space") raise Exception("Insufficient disk space")
return True return True
except OSError: except OSError:
# Create directory if it doesn't exist # 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 return True
def download_and_extract_adb(self) -> bool: def download_and_extract_adb(self) -> bool:
"""Download and extract ADB tools if not present.""" """Download and extract ADB tools if not present."""
if os.path.isfile(ADB_BINARY_PATH): # Use the centralized installer which will return the adb path (and
return True # perform a download if needed). If it returns a valid path, report
# success; otherwise return False.
try: try:
# Determine the correct URL based on platform adb_path = ensure_platform_tools_in_user_dir()
if OS_TYPE.startswith("linux"): if adb_path and os.path.isfile(adb_path):
adb_zip_url = ADB_LINUX_ZIP_URL # Ensure executable permissions on POSIX
elif OS_TYPE.startswith("win"): if os.name == "posix":
adb_zip_url = ADB_WIN_ZIP_URL try:
else: os.chmod(adb_path, 0o755)
print("Unsupported platform") except Exception:
return False pass
self._update_status("ADB available at: " + adb_path)
if not self.check_local_disk_space(): return True
return False 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
except Exception as e: except Exception as e:
print(f"Failed to download ADB: {e}") self._update_status(f"Failed to ensure platform-tools: {e}")
return False return False
def run_adb_command(self, args: list, capture_output: bool = True): def run_adb_command(self, args: list, capture_output: bool = True):
"""Run an ADB command and return output.""" """Run an ADB command and return output."""
cmd = [ADB_BINARY_PATH] + args cmd = [get_adb_binary_path()] + args
try: try:
if capture_output: if capture_output:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) 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: def pull_folder(self, remote_path: str, local_path: str) -> bool:
"""Pull files from Android device to local machine.""" """Pull files from Android device to local machine."""
# Normalize paths for better compatibility # Normalize paths and prepare
local_path = os.path.normpath(local_path) local_path = os.path.normpath(local_path)
remote_path = remote_path.strip() remote_path = remote_path.strip()
# Ensure local directory exists
try: try:
os.makedirs(local_path, exist_ok=True) os.makedirs(local_path, exist_ok=True)
except Exception as e: except Exception as e:
self._update_status(f"Failed to create local directory: {e}") self._update_status(f"Failed to create local directory: {e}")
return False return False
# For Windows root drives, ensure proper formatting # Warn if writing to root drive on Windows
if os.name == "nt": if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path) normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root: if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status( self._update_status(
"Warning: Transferring to root drive. Consider using a subfolder." "Warning: Transferring to root drive. Consider using a subfolder."
) )
cmd = [ADB_BINARY_PATH, "pull", remote_path, local_path] cmd = [get_adb_binary_path(), "pull", remote_path, local_path]
# Debug output for troubleshooting
self._update_status(f"Command: adb pull '{remote_path}' '{local_path}'") self._update_status(f"Command: adb pull '{remote_path}' '{local_path}'")
try: try:
# Start with initial progress
self._update_progress(0) self._update_progress(0)
self._update_status("Starting transfer...") self._update_status("Starting transfer...")
@@ -276,45 +406,29 @@ class ADBManager:
pct = self.parse_progress(line) pct = self.parse_progress(line)
if pct is not None: if pct is not None:
# Use explicit progress when available
self._update_progress(pct) self._update_progress(pct)
last_progress = pct last_progress = pct
last_update_time = current_time last_update_time = current_time
else: else:
# Improved progress estimation for large transfers
elapsed_time = current_time - start_time elapsed_time = current_time - start_time
time_since_last_update = current_time - last_update_time time_since_last_update = current_time - last_update_time
# Calculate progress based on multiple factors
should_update = False should_update = False
new_progress = last_progress new_progress = last_progress
# Time-based progress (update every 2 seconds)
if time_since_last_update >= 2.0 and last_progress < 95: if time_since_last_update >= 2.0 and last_progress < 95:
# Estimate progress based on activity and time
if line_count > 100: if line_count > 100:
# For large transfers, use a logarithmic approach activity_factor = min(line_count / 1000, 50)
activity_factor = min( time_factor = min(elapsed_time / 60, 40)
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) new_progress = min(activity_factor + time_factor, 95)
else: else:
# For smaller transfers, use the original approach
new_progress = min(last_progress + 10, 95) new_progress = min(last_progress + 10, 95)
should_update = True should_update = True
# Line-based progress (for very active transfers)
elif line_count % 50 == 0 and last_progress < 90: elif line_count % 50 == 0 and last_progress < 90:
# More conservative line-based updates
increment = max(1, min(5, 90 // (line_count // 50 + 1))) increment = max(1, min(5, 90 // (line_count // 50 + 1)))
new_progress = min(last_progress + increment, 90) new_progress = min(last_progress + increment, 90)
should_update = True should_update = True
# Update progress if needed
if should_update and new_progress > last_progress: if should_update and new_progress > last_progress:
self._update_progress(int(new_progress)) self._update_progress(int(new_progress))
last_progress = new_progress last_progress = new_progress
@@ -329,19 +443,17 @@ class ADBManager:
self.current_process = None self.current_process = None
return True return True
else: else:
# Capture error output for better debugging
error_msg = f"Transfer failed with code {proc.returncode}" error_msg = f"Transfer failed with code {proc.returncode}"
if hasattr(proc, "stderr") and proc.stderr: if hasattr(proc, "stderr") and proc.stderr:
try: try:
stderr_output = proc.stderr.read() stderr_output = proc.stderr.read()
if stderr_output: if stderr_output:
error_msg += f". Error: {stderr_output}" error_msg += f". Error: {stderr_output}"
except: except Exception:
pass pass
self._update_status(error_msg) self._update_status(error_msg)
self.current_process = None self.current_process = None
return False return False
except Exception as e: except Exception as e:
self._update_status(f"Transfer error: {e}") self._update_status(f"Transfer error: {e}")
self.current_process = None self.current_process = None
@@ -349,33 +461,25 @@ class ADBManager:
def push_folder(self, local_path: str, remote_path: str) -> bool: def push_folder(self, local_path: str, remote_path: str) -> bool:
"""Push files from local machine to Android device.""" """Push files from local machine to Android device."""
# Normalize paths for better compatibility
local_path = os.path.normpath(local_path) local_path = os.path.normpath(local_path)
remote_path = remote_path.strip() remote_path = remote_path.strip()
# Validate local path exists
if not os.path.exists(local_path): if not os.path.exists(local_path):
self._update_status(f"Local path does not exist: {local_path}") self._update_status(f"Local path does not exist: {local_path}")
return False return False
# For Windows root drives, ensure proper formatting
if os.name == "nt": if os.name == "nt":
# Check if this is a root drive (like C:\, D:\, etc.)
normalized_path = os.path.abspath(local_path) normalized_path = os.path.abspath(local_path)
drive_root = os.path.splitdrive(normalized_path)[0] + os.sep drive_root = os.path.splitdrive(normalized_path)[0] + os.sep
if normalized_path == drive_root: if normalized_path == drive_root:
# Root drive path like C:\ - this might cause issues with ADB
self._update_status( self._update_status(
"Warning: Pushing from root drive. Consider using a subfolder." "Warning: Pushing from root drive. Consider using a subfolder."
) )
cmd = [ADB_BINARY_PATH, "push", local_path, remote_path] cmd = [get_adb_binary_path(), "push", local_path, remote_path]
# Debug output for troubleshooting
self._update_status(f"Command: adb push '{local_path}' '{remote_path}'") self._update_status(f"Command: adb push '{local_path}' '{remote_path}'")
try: try:
# Start with initial progress
self._update_progress(0) self._update_progress(0)
self._update_status("Starting transfer...") self._update_status("Starting transfer...")
@@ -403,46 +507,13 @@ class ADBManager:
pct = self.parse_progress(line) pct = self.parse_progress(line)
if pct is not None: if pct is not None:
# Use explicit progress when available
self._update_progress(pct) self._update_progress(pct)
last_progress = pct last_progress = pct
last_update_time = current_time last_update_time = current_time
else: else:
# Improved progress estimation for large transfers
elapsed_time = current_time - start_time elapsed_time = current_time - start_time
time_since_last_update = current_time - last_update_time if elapsed_time >= 1.0 and last_progress < 90:
new_progress = min(last_progress + 20, 90)
# 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:
self._update_progress(int(new_progress)) self._update_progress(int(new_progress))
last_progress = new_progress last_progress = new_progress
last_update_time = current_time last_update_time = current_time
@@ -456,14 +527,13 @@ class ADBManager:
self.current_process = None self.current_process = None
return True return True
else: else:
# Capture error output for better debugging
error_msg = f"Push failed with code {proc.returncode}" error_msg = f"Push failed with code {proc.returncode}"
if hasattr(proc, "stderr") and proc.stderr: if hasattr(proc, "stderr") and proc.stderr:
try: try:
stderr_output = proc.stderr.read() stderr_output = proc.stderr.read()
if stderr_output: if stderr_output:
error_msg += f". Error: {stderr_output}" error_msg += f". Error: {stderr_output}"
except: except Exception:
pass pass
self._update_status(error_msg) self._update_status(error_msg)
self.current_process = None self.current_process = None
@@ -495,7 +565,7 @@ class ADBManager:
"Warning: Transferring to root drive. Consider using a subfolder." "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 # Debug output for troubleshooting
self._update_status(f"Command: adb pull '{remote_file_path}' '{local_file_path}'") 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." "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 # Debug output for troubleshooting
self._update_status(f"Command: adb push '{local_file_path}' '{remote_file_path}'") self._update_status(f"Command: adb push '{local_file_path}' '{remote_file_path}'")
@@ -681,6 +751,8 @@ class ADBManager:
return False return False
return False return False
# (Method intentionally removed - use top-level ensure_platform_tools_in_user_dir)
class LinuxMTPManager: class LinuxMTPManager:
"""Manages MTP operations on Linux systems.""" """Manages MTP operations on Linux systems."""
@@ -808,14 +880,13 @@ class LinuxMTPManager:
# Helper functions for standalone usage # 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: def is_adb_available() -> bool:
"""Check if ADB binary is available.""" """Check if ADB binary is available using the centralized resolver."""
return os.path.isfile(ADB_BINARY_PATH) try:
adb_path = get_adb_binary_path()
return os.path.isfile(adb_path)
except Exception:
return False
def get_platform_type() -> str: def get_platform_type() -> str:
+91 -21
View File
@@ -7,42 +7,76 @@ import os
import sys import sys
import tkinter as tk import tkinter as tk
from tkinter import messagebox, scrolledtext from tkinter import messagebox, scrolledtext
import tempfile
import stat
import subprocess
import sys
import os
def get_license_file_path(): def get_license_file_path() -> str:
"""Get the path to the license agreement file.""" """Return the per-user license-agreed file path.
# Get the directory where the application is running from
if getattr(sys, 'frozen', False): Uses platform-appropriate user config directory when possible:
# Running as executable - On Linux/macOS this will typically use ~/.config or ~/.android-file-handler fallback.
app_dir = os.path.dirname(sys.executable) - 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: 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__))) app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(app_dir, "dev-mode_license_agreed.ini")
return os.path.join(app_dir, "license_agreed.ini")
def check_license_agreement(): def check_license_agreement() -> bool:
"""Check if user has already agreed to the license.""" """Check if user has already agreed to the license."""
license_file = get_license_file_path() license_file = get_license_file_path()
try: try:
if os.path.exists(license_file): if os.path.exists(license_file):
with open(license_file, 'r') as f: with open(license_file, "r") as f:
content = f.read().strip() return f.read().strip() == "1"
return content == "1"
return False return False
except Exception: except Exception:
return False return False
def save_license_agreement(): def save_license_agreement() -> bool:
"""Save that the user has agreed to the license.""" """Persist that the user agreed to the license.
Writes atomically and sets secure file permissions on POSIX.
"""
license_file = get_license_file_path() license_file = get_license_file_path()
try: try:
# Create directory if it doesn't exist parent_dir = os.path.dirname(license_file)
os.makedirs(os.path.dirname(license_file), exist_ok=True) os.makedirs(parent_dir, exist_ok=True)
with open(license_file, 'w') as f:
f.write("1") # 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 return True
except Exception: except Exception:
return False return False
@@ -73,12 +107,48 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.""" 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): class LicenseAgreementFrame(tk.Frame):
"""License agreement UI frame that can be embedded in the main window.""" """License agreement UI frame that can be embedded in the main window."""
def __init__(self, parent, on_agree_callback): def __init__(self, parent, on_agree_callback):
"""Initialize the license agreement frame. """Initialize the license agreement frame.
Args: Args:
parent: Parent widget parent: Parent widget
on_agree_callback: Function to call when user agrees to license 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. Simple entry point to launch the Android file transfer application.
""" """
try: from gui.main_window import main
# Try relative import first (when used as module) from gui.license_agreement import run_windows_first_run_if_needed
from .gui.main_window import main
except ImportError:
# Fall back to direct import (when run directly)
from gui.main_window import main
if __name__ == "__main__": if __name__ == "__main__":
run_windows_first_run_if_needed()
main() main()