diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ee2481a Binary files /dev/null and b/.DS_Store differ diff --git a/src/app/core/configurations.py b/src/app/core/configurations.py index 5e1e06b..478603b 100644 --- a/src/app/core/configurations.py +++ b/src/app/core/configurations.py @@ -1,5 +1,6 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import logging import os import platform @@ -84,6 +85,27 @@ def device_downloads_path(cls, device: Device) -> str: return Settings.downloads_path +_logger = logging.getLogger(__name__) + +_PHONE_SCRIPTS_DIR = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless') + + +def _checked_script(name: str) -> str: + """Return expanded path; log a warning if the file does not exist.""" + path = os.path.join(_PHONE_SCRIPTS_DIR, name) + if not os.path.exists(path): + _logger.warning("Script not found: %s", path) + return path + + +class AppScripts: + """Centralised paths for helper shell scripts.""" + STREAM_SCRIPT = _checked_script('phone-stream.sh') + TRANSPORT_SCRIPT = _checked_script('phone-transport.sh') + # общее ядро range-стрима: его же гоняет phone-stream.sh и CLI + ADB_STREAM = _checked_script('adb_stream.py') + + class Resources: __metaclass__ = Singleton diff --git a/src/app/core/managers.py b/src/app/core/managers.py index 750aeb5..ee4e0bd 100644 --- a/src/app/core/managers.py +++ b/src/app/core/managers.py @@ -54,11 +54,14 @@ def up(cls) -> bool: def get_device(cls) -> Device: return cls.__device + DEFAULT_PATH = 'sdcard' + @classmethod def set_device(cls, device: Device) -> bool: if device: cls.clear() cls.__device = device + cls.__path.append(cls.DEFAULT_PATH) return True @classmethod diff --git a/src/app/data/repositories/__init__.py b/src/app/data/repositories/__init__.py index 1b35c73..9918d44 100644 --- a/src/app/data/repositories/__init__.py +++ b/src/app/data/repositories/__init__.py @@ -79,6 +79,22 @@ def upload(cls, progress_callback: callable, source: str) -> (str, str): ) +class StorageRepository: + """Delegates to android_adb.StorageRepository (only available with EXTERNAL_TOOL_ADB core).""" + + @staticmethod + def get_device_model(device_id: str) -> str: + return android_adb.StorageRepository.get_device_model(device_id) + + @staticmethod + def get_disk_info(device_id: str, path: str = '/sdcard') -> dict: + return android_adb.StorageRepository.get_disk_info(device_id, path) + + @staticmethod + def get_sd_card_path(device_id: str) -> str: + return android_adb.StorageRepository.get_sd_card_path(device_id) + + class DeviceRepository: @classmethod def devices(cls) -> (List[Device], str): diff --git a/src/app/data/repositories/android_adb.py b/src/app/data/repositories/android_adb.py index 5142e14..af721da 100644 --- a/src/app/data/repositories/android_adb.py +++ b/src/app/data/repositories/android_adb.py @@ -1,5 +1,7 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import os +import threading from typing import List from app.core.configurations import Settings @@ -35,6 +37,10 @@ def file(cls, path: str) -> (File, str): file.path = path return file, response.ErrorData + # Таймаут на листинг каталога (секунды). + # 25с — достаточно для большинства папок даже по Wi-Fi. + FILES_TIMEOUT = 25 + @classmethod def files(cls) -> (List[File], str): if not ADBManager.get_device(): @@ -42,9 +48,13 @@ def files(cls) -> (List[File], str): path = ADBManager.path() args = adb.ShellCommand.LS_ALL_LIST + [path] - response = adb.shell(ADBManager.get_device().id, [shlex.join(args)]) + response = adb.shell(ADBManager.get_device().id, [shlex.join(args)], + timeout=cls.FILES_TIMEOUT) if not response.IsSuccessful and response.ExitCode != 1: - return [], response.ErrorData or response.OutputData + err = response.ErrorData or response.OutputData or '' + # Понятное сообщение при таймауте/offline уже формируется в CommonProcess, + # просто пробрасываем его + return [], err if not response.OutputData: return [], response.ErrorData @@ -63,7 +73,9 @@ def files(cls) -> (List[File], str): # Build one script to test all symlinks using shared helper; safely quotes each path script = build_test_d_batch_script(symlink_paths) cmd = shlex.join(['sh', '-c', script]) - batch_resp = adb.shell(ADBManager.get_device().id, [cmd]) + # Даём пропорциональный таймаут: не менее 10с + sym_timeout = max(10, min(len(symlink_paths) // 2, 20)) + batch_resp = adb.shell(ADBManager.get_device().id, [cmd], timeout=sym_timeout) if batch_resp.IsSuccessful and batch_resp.OutputData: status = parse_test_d_batch_output(batch_resp.OutputData) @@ -116,13 +128,53 @@ def call(self, data: str): elif data: self.messages.append(data) + @staticmethod + def _remote_file_size(source: str) -> int: + try: + command = shlex.join(['stat', '-c', '%s', source]) + response = adb.shell(ADBManager.get_device().id, [command]) + if response.IsSuccessful and response.OutputData: + return int(response.OutputData.strip()) + except (ValueError, AttributeError, TypeError): + pass + return 0 + + @staticmethod + def _poll_download_progress(dest_file, total, callback, name, stop_event): + # adb pull only prints `[ N%]` progress to a TTY; when its output is + # piped (as here) it stays silent until the transfer finishes, so the + # progress bar never moved. Derive progress from the size of the file + # being written locally instead. + while not stop_event.wait(0.25): + try: + current = os.path.getsize(dest_file) + except OSError: + continue + callback(name, min(int(current * 100 / total), 99)) + @classmethod def download(cls, progress_callback: callable, source: str, destination: str) -> (str, str): if not destination: destination = Settings.device_downloads_path(ADBManager.get_device()) if ADBManager.get_device() and source and destination: helper = cls.UpDownHelper(progress_callback) + + name = source.rstrip('/').rsplit('/', 1)[-1] + total = cls._remote_file_size(source) + dest_file = os.path.join(destination, name) if os.path.isdir(destination) else destination + stop_event = threading.Event() + if total > 0 and progress_callback: + threading.Thread( + target=cls._poll_download_progress, + args=(dest_file, total, progress_callback, name, stop_event), + daemon=True, + ).start() + response = adb.pull(ADBManager.get_device().id, source, destination, helper.call) + stop_event.set() + if response.IsSuccessful and total > 0 and progress_callback: + progress_callback(name, 100) + if not response.IsSuccessful: return None, response.ErrorData or "\n".join(helper.messages) @@ -140,6 +192,23 @@ def new_folder(cls, name) -> (str, str): return None, response.ErrorData or response.OutputData return response.OutputData, response.ErrorData + @classmethod + def fetch_exif_thumbnail(cls, device_id: str, path: str): + """Returns (jpeg_bytes, None) or (None, error_str)""" + try: + import piexif + from app.services.adb import exec_out_head + raw = exec_out_head(device_id, path) + if not raw: + return None, "empty response" + exif = piexif.load(raw) + thumb = exif.get("thumbnail") + if thumb: + return thumb, None + return None, "no thumbnail in EXIF" + except Exception as e: + return None, str(e) + @classmethod def upload(cls, progress_callback: callable, source: str) -> (str, str): if ADBManager.get_device() and ADBManager.path() and source: @@ -152,6 +221,76 @@ def upload(cls, progress_callback: callable, source: str) -> (str, str): return None, None +class StorageRepository: + """Helpers for device model name, disk usage, and SD card detection.""" + + @staticmethod + def get_device_model(device_id: str) -> str: + """Return ro.product.model, e.g. 'Pixel 6'.""" + try: + response = adb.shell(device_id, [shlex.join(adb.ShellCommand.GETPROP_PRODUCT_MODEL)], + timeout=5) + if response.IsSuccessful and response.OutputData: + return response.OutputData.strip() + except Exception: + pass + return '' + + @staticmethod + def get_disk_info(device_id: str, path: str = '/sdcard') -> dict: + """Return {'avail_gb': float, 'total_gb': float} for path, or empty dict on failure. + + Tries `df -k ` (POSIX, Android busybox) first; the last data line has + columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted-on. + Falls back to `df ` which may give KB or 512-byte blocks depending on busybox.""" + for flag in ['-k', '']: + try: + cmd_parts = ['df'] + ([flag] if flag else []) + [path] + response = adb.shell(device_id, [shlex.join(cmd_parts)], timeout=5) + if not response.IsSuccessful or not response.OutputData: + continue + lines = [l.strip() for l in response.OutputData.splitlines() if l.strip()] + # skip header, take last data line (handles line-wrapped output) + data_lines = [l for l in lines if not l.startswith('Filesystem')] + if not data_lines: + continue + parts = data_lines[-1].split() + # expect at least 4 numeric columns after filesystem name + # handle wrapped: if first line ends with fs name only, parts may be short + if len(parts) < 4: + continue + # columns: [filesystem, total, used, avail, ...] (1K-blocks with -k) + total_kb = int(parts[1]) + avail_kb = int(parts[3]) + if flag == '-k' or total_kb > 1024 * 1024: + # values are in KB + divisor = 1024 * 1024 + else: + # values are in 512-byte blocks (some old busybox without -k) + divisor = 2 * 1024 * 1024 + return { + 'avail_gb': round(avail_kb / divisor, 1), + 'total_gb': round(total_kb / divisor, 1), + } + except Exception: + continue + return {} + + @staticmethod + def get_sd_card_path(device_id: str) -> str: + """Return /storage/XXXX-XXXX path if external SD card is present, else empty string.""" + import re + try: + response = adb.shell(device_id, [shlex.join(['ls', '/storage/'])], timeout=5) + if response.IsSuccessful and response.OutputData: + for entry in response.OutputData.split(): + if re.match(r'^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$', entry.strip()): + return '/storage/' + entry.strip() + except Exception: + pass + return '' + + class DeviceRepository: @classmethod def devices(cls) -> (List[Device], str): diff --git a/src/app/gui/explorer/devices.py b/src/app/gui/explorer/devices.py index 9f1a734..c75c544 100644 --- a/src/app/gui/explorer/devices.py +++ b/src/app/gui/explorer/devices.py @@ -1,5 +1,6 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import subprocess from typing import Any from PyQt5 import QtGui, QtCore @@ -8,14 +9,22 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QStyledItemDelegate, QStyleOptionViewItem, QApplication, \ QStyle, QListView -from app.core.configurations import Resources +from app.core.configurations import AppScripts, Resources from app.core.main import Adb from app.core.managers import Global from app.data.models import DeviceType, MessageData, MessageType -from app.data.repositories import DeviceRepository +from app.data.repositories import DeviceRepository, StorageRepository from app.helpers.tools import AsyncRepositoryWorker, read_string_from_file from app.gui.widgets.circular_progress import CircularProgress +def _devices_with_auto_connect(): + """Обёртка для DeviceRepository.devices с авто-подключением по Wi-Fi-adb через phone-transport.sh.""" + try: + subprocess.run(['bash', AppScripts.TRANSPORT_SCRIPT], capture_output=True, timeout=6) + except Exception: + pass + return DeviceRepository.devices() + class DeviceItemDelegate(QStyledItemDelegate): def sizeHint(self, option: 'QStyleOptionViewItem', index: QtCore.QModelIndex) -> QtCore.QSize: @@ -123,7 +132,7 @@ def update(self): worker = AsyncRepositoryWorker( name="Devices", worker_id=self.DEVICES_WORKER_ID, - repository_method=DeviceRepository.devices, + repository_method=_devices_with_auto_connect, arguments=(), response_callback=self._async_response ) @@ -166,6 +175,16 @@ def open(self): if self.device.id: if Adb.manager().set_device(self.device): Global().communicate.files.emit() + # Fetch device info in background and broadcast via signal + worker = AsyncRepositoryWorker( + name="DeviceInfo", + worker_id=201, + repository_method=self._fetch_device_info, + arguments=(self.device.id,), + response_callback=self._on_device_info + ) + if Adb.worker().work(worker): + worker.start() else: Global().communicate.notification.emit( MessageData( @@ -174,3 +193,19 @@ def open(self): body="Could not open the device %s" % Adb.manager().get_device().name ) ) + + @staticmethod + def _fetch_device_info(device_id: str): + """Called in background thread — returns (model, avail_gb, total_gb, sd_path).""" + model = StorageRepository.get_device_model(device_id) + disk = StorageRepository.get_disk_info(device_id, '/sdcard') + sd = StorageRepository.get_sd_card_path(device_id) + avail = disk.get('avail_gb', 0.0) + total = disk.get('total_gb', 0.0) + return (model, avail, total, sd), None + + @staticmethod + def _on_device_info(data, error): + if data: + model, avail, total, sd = data + Global().communicate.device_info_ready.emit(model, float(avail), float(total), sd or '') diff --git a/src/app/gui/explorer/files.py b/src/app/gui/explorer/files.py index f9bf469..0dcb667 100644 --- a/src/app/gui/explorer/files.py +++ b/src/app/gui/explorer/files.py @@ -1,23 +1,29 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import os +import subprocess import sys +import tempfile +import webbrowser from typing import Any from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import Qt, QPoint, QModelIndex, QAbstractListModel, QVariant, QRect, QSize, QEvent, QObject -from PyQt5.QtGui import QPixmap, QColor, QPalette, QKeySequence +from PyQt5.QtCore import Qt, QPoint, QModelIndex, QAbstractListModel, QVariant, QRect, QSize, QEvent, QObject, QUrl, QThreadPool, QThread, pyqtSignal +from PyQt5.QtGui import QPixmap, QColor, QPalette, QKeySequence, QDesktopServices from PyQt5.QtWidgets import QMenu, QAction, QMessageBox, QFileDialog, QStyle, QWidget, QStyledItemDelegate, \ QStyleOptionViewItem, QApplication, QListView, QVBoxLayout, QLabel, QSizePolicy, QHBoxLayout, QTextEdit, \ QMainWindow -from app.core.configurations import Resources +from app.core.configurations import AppScripts, Resources, Settings from app.core.main import Adb -from app.core.managers import Global +from app.core.managers import Global, ADBManager from app.data.models import FileType, MessageData, MessageType from app.data.repositories import FileRepository from app.gui.explorer.toolbar import ParentButton, UploadTools, PathBar -from app.helpers.tools import AsyncRepositoryWorker, ProgressCallbackHelper, read_string_from_file +from app.helpers.tools import (AsyncRepositoryWorker, ProgressCallbackHelper, read_string_from_file, + ThumbnailWorker, thumbnail_cancel_pending, _thumbnail_pool) from app.gui.widgets.circular_progress import CircularProgress +from app.services import stream_server class FileHeaderWidget(QWidget): @@ -149,10 +155,17 @@ def paint(self, painter: QtGui.QPainter, option: 'QStyleOptionViewItem', index: ) +# Не запускать превью если в папке больше N файлов (по Wi-Fi это убийца производительности) +_THUMB_MAX_FILES = 300 + class FileListModel(QAbstractListModel): def __init__(self, parent=None): super().__init__(parent) self.items = [] + self._thumb_cache = {} # path -> QPixmap + self._thumb_pending = set() # paths currently being fetched + self._thumbs_enabled = True # выключается для больших папок + Global.communicate.thumbnail_ready.connect(self._on_thumbnail_ready) def clear(self): self.beginResetModel() @@ -160,11 +173,34 @@ def clear(self): self.endResetModel() def populate(self, files: list): + # Отменяем все старые незавершённые превью + thumbnail_cancel_pending() self.beginResetModel() self.items.clear() + self._thumb_cache.clear() + self._thumb_pending.clear() self.items = files + self._thumbs_enabled = len(files) <= _THUMB_MAX_FILES self.endResetModel() + def _on_thumbnail_ready(self, path: str, jpeg: bytes): + # Discard stale results after folder change + if path not in self._thumb_pending: + return + pixmap = QPixmap() + pixmap.loadFromData(jpeg) + if not pixmap.isNull(): + self._thumb_cache[path] = pixmap.scaled( + 32, 32, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._thumb_pending.discard(path) + # Find row and emit dataChanged + for row, item in enumerate(self.items): + if item.path == path: + idx = self.index(row, 0) + self.dataChanged.emit(idx, idx, [Qt.DecorationRole]) + break + def rowCount(self, parent: QModelIndex = ...) -> int: return len(self.items) @@ -213,10 +249,133 @@ def data(self, index: QModelIndex, role: int = ...) -> Any: elif role == Qt.EditRole: return self.items[index.row()].name elif role == Qt.DecorationRole: + file = self.items[index.row()] + if self._thumbs_enabled and file.name.lower().endswith(('.jpg', '.jpeg', '.png', '.heic')): + if file.path in self._thumb_cache: + return self._thumb_cache[file.path] + if file.path not in self._thumb_pending: + device = ADBManager.get_device() + if device: + self._thumb_pending.add(file.path) + mtime_iso = file.raw_date.isoformat() if file.raw_date else "" + worker = ThumbnailWorker(device.id, file.path, mtime_iso) + # Используем отдельный пул с лимитом 2 потока вместо globalInstance + _thumbnail_pool.start(worker) return QPixmap(self.icon_path(index)).scaled(32, 32, Qt.KeepAspectRatio) return QVariant() +_TRANSPORT_SCRIPT = AppScripts.TRANSPORT_SCRIPT + +_TRANSPORT_KIND_LABELS = { + 'usb': 'USB', + 'wifi-ssh': 'Wi-Fi (SSH)', + 'wifi-adb': 'Wi-Fi (adb)', + 'none': 'нет связи', +} + + +class _TransportWorker(QThread): + """Фоновый поток: запускает phone-transport.sh и эмитирует человекочитаемый канал.""" + result = pyqtSignal(str) # label + + def run(self): + try: + proc = subprocess.run( + ['bash', _TRANSPORT_SCRIPT], + capture_output=True, text=True, timeout=6 + ) + line = proc.stdout.strip().split('\n')[0] + kind = line.split('|')[0] if '|' in line else line + label = _TRANSPORT_KIND_LABELS.get(kind, kind or '—') + except Exception: + label = '—' + self.result.emit(label) + + +class DeviceInfoBar(QWidget): + """Thin bar above file list showing device model, free space, SD-card shortcut, and transport channel.""" + + def __init__(self, parent=None): + super(DeviceInfoBar, self).__init__(parent) + self._sd_path = '' + self._transport_worker = None + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + + self.model_label = QLabel('', self) + self.model_label.setStyleSheet('font-weight: bold;') + layout.addWidget(self.model_label) + + layout.addStretch(1) + + self.channel_label = QLabel('', self) + self.channel_label.setStyleSheet('color: #666;') + layout.addWidget(self.channel_label) + + self.storage_label = QLabel('', self) + layout.addWidget(self.storage_label) + + from PyQt5.QtWidgets import QPushButton + self.sd_button = QPushButton('SD card', self) + self.sd_button.setVisible(False) + self.sd_button.setFlat(True) + self.sd_button.setStyleSheet('color: #4a90d9; text-decoration: underline; border: none; padding: 0 4px;') + self.sd_button.clicked.connect(self._go_sd) + layout.addWidget(self.sd_button) + + Global().communicate.device_info_ready.connect(self._update) + self.setLayout(layout) + + def _update(self, model: str, avail: float, total: float, sd_path: str): + self._sd_path = sd_path + self.model_label.setText(model or '') + if total > 0: + self.storage_label.setText('%.1f GB free / %.1f GB' % (avail, total)) + else: + self.storage_label.setText('') + self.sd_button.setVisible(bool(sd_path)) + # Запускаем фоновый запрос канала + self.channel_label.setText('Канал: …') + self._start_transport_query() + + def _start_transport_query(self): + if self._transport_worker is not None: + if self._transport_worker.isRunning(): + return + # Disconnect stale signal before replacing worker + try: + self._transport_worker.result.disconnect(self._on_transport_result) + except (TypeError, RuntimeError): + pass + self._transport_worker.quit() + self._transport_worker.wait() + self._transport_worker = _TransportWorker(self) + self._transport_worker.result.connect(self._on_transport_result) + self._transport_worker.start() + + def hideEvent(self, event): + """Stop the transport worker when the widget is hidden to avoid signals into dead objects.""" + if self._transport_worker is not None and self._transport_worker.isRunning(): + try: + self._transport_worker.result.disconnect(self._on_transport_result) + except (TypeError, RuntimeError): + pass + self._transport_worker.quit() + self._transport_worker.wait() + super(DeviceInfoBar, self).hideEvent(event) + + def _on_transport_result(self, label: str): + self.channel_label.setText('Канал: ' + label) + + def _go_sd(self): + if not self._sd_path: + return + file, error = FileRepository.file(self._sd_path) + if file and Adb.manager().go(file): + Global().communicate.files__refresh.emit() + + class FileExplorerWidget(QWidget): FILES_WORKER_ID = 300 DOWNLOAD_WORKER_ID = 399 @@ -228,6 +387,9 @@ def __init__(self, parent=None): self.toolbar = FileExplorerToolbar(self) self.main_layout.addWidget(self.toolbar) + self.device_info_bar = DeviceInfoBar(self) + self.main_layout.addWidget(self.device_info_bar) + self.header = FileHeaderWidget(self) self.main_layout.addWidget(self.header) @@ -274,6 +436,8 @@ def files(self): def update(self): super(FileExplorerWidget, self).update() + # Отменяем устаревшие превью при переходе в другую папку + thumbnail_cancel_pending() worker = AsyncRepositoryWorker( name="Files", worker_id=self.FILES_WORKER_ID, @@ -304,17 +468,21 @@ def _async_response(self, files: list, error: str): if error: print(error, file=sys.stderr) if not files: + # Форматируем понятные сообщения для типовых ошибок + err_text = str(error) Global().communicate.notification.emit( MessageData( - title='Files', - timeout=15000, - body=str(error), + title='Ошибка загрузки папки', + timeout=20000, + body=err_text, message_type=MessageType.ERROR_MESSAGE, ) ) if not files: self.empty_label.setHidden(False) + self.list.setHidden(True) else: + self.empty_label.setHidden(True) self.list.setHidden(False) self.model.populate(files) self.list.setFocus() @@ -328,7 +496,16 @@ def eventFilter(self, obj: 'QObject', event: 'QEvent') -> bool: return super(FileExplorerWidget, self).eventFilter(obj, event) def open(self, index: QModelIndex = ...): - if Adb.manager().open(self.model.items[index.row()]): + item = self.model.items[index.row()] + ext = os.path.splitext(item.name)[1].lower() + # Guard: skip streaming for directories and symlinks-to-directories + is_real_file = (not item.isdir and + not (item.type == FileType.LINK and item.link_type == FileType.DIRECTORY)) + if is_real_file and ext in self._VIDEO_EXTENSIONS: + # Видео — стримить в IINA вместо выкачки + self.stream_file() + return + if Adb.manager().open(item): Global().communicate.files__refresh.emit() def context_menu(self, pos: QPoint): @@ -363,6 +540,15 @@ def context_menu(self, pos: QPoint): action_download_to.triggered.connect(self.download_to) menu.addAction(action_download_to) + if self.file and not self.file.isdir: + action_stream = QAction('Stream', self) + action_stream.triggered.connect(self.stream_file) + menu.addAction(action_stream) + + action_copy_stream = QAction('Copy stream link', self) + action_copy_stream.triggered.connect(self.copy_stream_link) + menu.addAction(action_copy_stream) + menu.addSeparator() action_properties = QAction('Properties', self) @@ -395,24 +581,50 @@ def rename(self): self.list.edit(self.list.currentIndex()) def open_file(self): - # QDesktopServices.openUrl(QUrl.fromLocalFile("downloaded_path")) open via external app if not self.file.isdir: - data, error = FileRepository.open_file(self.file) - if error: + temp_dir = os.path.join(tempfile.gettempdir(), 'adbfe_open') + os.makedirs(temp_dir, exist_ok=True) + local_path = os.path.join(temp_dir, self.file.name) + + def open_response(data, error): + if error: + Global().communicate.notification.emit( + MessageData( + title='Open error', + timeout=15000, + body=str(error), + message_type=MessageType.ERROR_MESSAGE, + ) + ) + else: + QDesktopServices.openUrl(QUrl.fromLocalFile(local_path)) + + helper = ProgressCallbackHelper() + worker = AsyncRepositoryWorker( + worker_id=self.DOWNLOAD_WORKER_ID, + name="Open", + repository_method=FileRepository.download, + response_callback=open_response, + arguments=( + helper.progress_callback.emit, self.file.path, temp_dir + ) + ) + if Adb.worker().work(worker): Global().communicate.notification.emit( MessageData( - title='File', - timeout=15000, - body=str(error), - message_type=MessageType.ERROR_MESSAGE, + title="Opening", + message_type=MessageType.LOADING_MESSAGE, + message_catcher=worker.set_loading_widget ) ) - else: - self.text_view_window = TextView(self.file.name, data) - self.text_view_window.show() + helper.setup(worker, worker.update_loading_widget) + worker.start() def delete(self): - file_names = ', '.join(map(lambda f: f.name, self.files)) + files = list(self.files) if self.files is not None else [] + if not files: + return + file_names = ', '.join(map(lambda f: f.name, files)) reply = QMessageBox.critical( self, 'Delete', @@ -421,7 +633,7 @@ def delete(self): ) if reply == QMessageBox.Yes: - for file in self.files: + for file in files: data, error = FileRepository.delete(file) if data: Global().communicate.notification.emit( @@ -470,6 +682,58 @@ def download_files(self, destination: str = None): helper.setup(worker, worker.update_loading_widget) worker.start() + def _get_stream_url(self): + """Start (or reuse) a stream server for the selected file and return the URL.""" + device = ADBManager.get_device() + if not device: + raise RuntimeError("No device connected") + adb_path = Settings.adb_path() + return stream_server.start_stream(adb_path, device.id, self.file.path) + + _VIDEO_EXTENSIONS = { + '.mp4', '.mov', '.mkv', '.avi', '.m4v', '.webm', '.3gp', '.ts' + } + + def stream_file(self): + try: + if not self.file: + return + remote_path = self.file.path + stream_script = AppScripts.STREAM_SCRIPT + if not os.path.exists(stream_script): + Global().communicate.notification.emit( + MessageData( + timeout=10000, + title="Stream error", + body="Stream script not found: %s" % stream_script, + message_type=MessageType.ERROR_MESSAGE, + ) + ) + return + subprocess.Popen( + ['bash', stream_script, remote_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + print("Stream error: %s" % e, file=sys.stderr) + Global().communicate.notification.emit( + MessageData( + timeout=10000, + title="Stream error", + body=str(e), + message_type=MessageType.ERROR_MESSAGE, + ) + ) + + def copy_stream_link(self): + try: + url = self._get_stream_url() + QApplication.clipboard().setText(url) + except Exception as e: + print("Copy stream link error: %s" % e, file=sys.stderr) + def file_properties(self): file, error = FileRepository.file(self.file.path) file = file if file else self.file diff --git a/src/app/gui/window.py b/src/app/gui/window.py index 0c1cd74..4f4a2aa 100644 --- a/src/app/gui/window.py +++ b/src/app/gui/window.py @@ -1,5 +1,6 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QMainWindow, QAction, qApp, QInputDialog, QMenuBar, QMessageBox @@ -11,9 +12,24 @@ from app.gui.explorer import MainExplorer from app.gui.help import About from app.gui.notification import NotificationCenter +from app.helpers.mount import mount_phone, mount_phone_system, unmount_phone +from app.services import stream_server from app.helpers.tools import AsyncRepositoryWorker +class MountWorker(QThread): + """Фоновый поток для запуска скриптов монтирования без блокировки UI.""" + finished = pyqtSignal(bool, str) + + def __init__(self, fn, parent=None): + super().__init__(parent) + self._fn = fn + + def run(self): + success, message = self._fn() + self.finished.emit(success, message) + + class MenuBar(QMenuBar): CONNECT_WORKER_ID = 100 DISCONNECT_WORKER_ID = 101 @@ -23,8 +39,29 @@ def __init__(self, parent): self.about = About() self.file_menu = self.addMenu('&File') + self.phone_menu = self.addMenu('&Phone') self.help_menu = self.addMenu('&Help') + # --- Меню Phone --- + mount_action = QAction('&Mount Phone', self) + mount_action.setShortcut('Alt+M') + mount_action.setToolTip('Смонтировать внутреннюю память телефона в ~/Phone') + mount_action.triggered.connect(self._do_mount_phone) + self.phone_menu.addAction(mount_action) + + mount_system_action = QAction('Mount Phone (&System)', self) + mount_system_action.setToolTip('Смонтировать системный раздел в ~/Phone-System') + mount_system_action.triggered.connect(self._do_mount_phone_system) + self.phone_menu.addAction(mount_system_action) + + unmount_action = QAction('&Unmount Phone', self) + unmount_action.setShortcut('Alt+U') + unmount_action.setToolTip('Размонтировать телефон') + unmount_action.triggered.connect(self._do_unmount_phone) + self.phone_menu.addAction(unmount_action) + + self._mount_worker = None # держим ссылку, чтобы QThread не был собран GC + self.connect_action = QAction(QIcon(Resources.icon_link), '&Connect', self) self.connect_action.setShortcut('Alt+C') self.connect_action.triggered.connect(self.connect_device) @@ -49,6 +86,36 @@ def __init__(self, parent): about_action.triggered.connect(self.about.show) self.help_menu.addAction(about_action) + # --- Phone mount/unmount --- + + def _run_mount_op(self, fn, label: str): + """Запускает операцию монтирования в фоновом потоке.""" + if self._mount_worker and self._mount_worker.isRunning(): + QMessageBox.information(self.parent(), 'Phone', 'Операция уже выполняется, подождите.') + return + Global().communicate.status_bar.emit(f'Phone: {label}...', 0) + self._mount_worker = MountWorker(fn, parent=self) + self._mount_worker.finished.connect(lambda ok, msg: self._on_mount_done(ok, msg, label)) + self._mount_worker.start() + + def _on_mount_done(self, success: bool, message: str, label: str): + Global().communicate.status_bar.emit(f'Phone: {label} завершено.', 5000) + if success: + Global().communicate.notification.emit( + MessageData(title=f'Phone — {label}', body=message or 'Готово', timeout=8000) + ) + else: + QMessageBox.warning(self.parent(), f'Phone — {label}', message or 'Неизвестная ошибка') + + def _do_mount_phone(self): + self._run_mount_op(mount_phone, 'Mount Phone') + + def _do_mount_phone_system(self): + self._run_mount_op(mount_phone_system, 'Mount Phone (System)') + + def _do_unmount_phone(self): + self._run_mount_op(unmount_phone, 'Unmount Phone') + def disconnect(self): worker = AsyncRepositoryWorker( worker_id=self.DISCONNECT_WORKER_ID, @@ -179,6 +246,13 @@ def notify(self, data: MessageData): data.message_catcher(message) def closeEvent(self, event): + # Закрыть HTTP-серверы стрима: каждая скопированная ссылка держит свой + # слушающий сокет, и без этого они жили до конца процесса. + try: + stream_server.stop_all() + except Exception: + pass + if Adb.core == Adb.EXTERNAL_TOOL_ADB: if Settings.adb_kill_server_at_exit() is None: reply = QMessageBox.question(self, 'ADB Server', "Do you want to kill adb server?", diff --git a/src/app/helpers/mount.py b/src/app/helpers/mount.py new file mode 100644 index 0000000..150d8da --- /dev/null +++ b/src/app/helpers/mount.py @@ -0,0 +1,88 @@ +# ADB File Explorer +# Copyright (C) 2022 Azat Aldeshov +""" +Вспомогательные функции для монтирования телефона через adbfs-rootless. +Скрипты находятся в ~/PhoneAsExtStorage/adbfs-rootless/. +""" + +import os +import subprocess + +_SCRIPTS_DIR = os.path.expanduser("~/PhoneAsExtStorage/adbfs-rootless") +_MOUNT_SCRIPT = os.path.join(_SCRIPTS_DIR, "mount-phone.sh") +_UNMOUNT_SCRIPT = os.path.join(_SCRIPTS_DIR, "unmount-phone.sh") +_PHONE_MOUNT_POINT = os.path.expanduser("~/Phone") + + +def is_mounted() -> bool: + """Проверяет, смонтирован ли телефон (внутренняя память ~/Phone).""" + try: + result = subprocess.run( + ["mount"], + capture_output=True, + text=True, + timeout=5 + ) + return _PHONE_MOUNT_POINT + " " in result.stdout or \ + result.stdout.endswith(_PHONE_MOUNT_POINT) + except Exception: + return False + + +def _run_script(script_path: str, *args) -> tuple: + """ + Запускает скрипт и ждёт завершения (скрипты быстрые, ~4с). + Возвращает (stdout, stderr, returncode). + """ + cmd = ["/bin/bash", script_path] + list(args) + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + return result.stdout, result.stderr, result.returncode + except subprocess.TimeoutExpired: + return "", "Скрипт не завершился за 30 секунд", 1 + except FileNotFoundError: + return "", f"Скрипт не найден: {script_path}", 1 + except Exception as e: + return "", str(e), 1 + + +def mount_phone() -> tuple: + """ + Монтирует внутреннюю память телефона в ~/Phone (и SD в ~/Phone-SD). + Finder открывается автоматически скриптом. + Возвращает (success: bool, message: str). + """ + stdout, stderr, code = _run_script(_MOUNT_SCRIPT) + if code == 0: + return True, stdout.strip() or "Телефон успешно смонтирован в ~/Phone" + else: + return False, stderr.strip() or stdout.strip() or "Ошибка монтирования" + + +def mount_phone_system() -> tuple: + """ + Монтирует системный раздел телефона в ~/Phone-System. + Возвращает (success: bool, message: str). + """ + stdout, stderr, code = _run_script(_MOUNT_SCRIPT, "system") + if code == 0: + return True, stdout.strip() or "Системный раздел смонтирован в ~/Phone-System" + else: + return False, stderr.strip() or stdout.strip() or "Ошибка монтирования системного раздела" + + +def unmount_phone() -> tuple: + """ + Размонтирует все точки монтирования телефона. + Возвращает (success: bool, message: str). + """ + stdout, stderr, code = _run_script(_UNMOUNT_SCRIPT) + if code == 0: + return True, stdout.strip() or "Телефон успешно размонтирован" + else: + return False, stderr.strip() or stdout.strip() or "Ошибка размонтирования" diff --git a/src/app/helpers/thumb_cache.py b/src/app/helpers/thumb_cache.py new file mode 100644 index 0000000..2ecd2df --- /dev/null +++ b/src/app/helpers/thumb_cache.py @@ -0,0 +1,37 @@ +"""Disk cache for ADB EXIF thumbnails.""" +import hashlib +import os +import tempfile + + +def _cache_dir() -> str: + d = os.path.join(tempfile.gettempdir(), "adbfe_thumbs") + os.makedirs(d, exist_ok=True) + return d + + +def _key(serial: str, path: str, mtime_iso: str) -> str: + raw = f"{serial}:{path}:{mtime_iso}" + return hashlib.sha256(raw.encode()).hexdigest() + ".jpg" + + +def get(serial: str, path: str, mtime_iso: str): + """Return cached bytes or None.""" + fpath = os.path.join(_cache_dir(), _key(serial, path, mtime_iso)) + if os.path.exists(fpath): + try: + with open(fpath, "rb") as f: + return f.read() + except Exception: + return None + return None + + +def put(serial: str, path: str, mtime_iso: str, data: bytes) -> None: + """Store bytes in disk cache.""" + fpath = os.path.join(_cache_dir(), _key(serial, path, mtime_iso)) + try: + with open(fpath, "wb") as f: + f.write(data) + except Exception: + pass diff --git a/src/app/helpers/tools.py b/src/app/helpers/tools.py index 6e358f6..e3b412d 100644 --- a/src/app/helpers/tools.py +++ b/src/app/helpers/tools.py @@ -8,7 +8,7 @@ import shlex from PyQt5 import QtCore -from PyQt5.QtCore import QThread, QObject, QFile, QIODevice, QTextStream +from PyQt5.QtCore import QThread, QObject, QFile, QIODevice, QTextStream, QRunnable, QThreadPool from PyQt5.QtWidgets import QWidget from app.data.models import MessageData @@ -30,12 +30,15 @@ class CommonProcess: arguments -- array list of arguments stdout -- define stdout (default subprocess.PIPE) stdout_callback -- callable function, params: (data: str) -> None (default None) + timeout -- subprocess timeout in seconds (default None = no limit, pass int to limit) """ - def __init__(self, arguments: list, stdout=subprocess.PIPE, stdout_callback: callable = None): + def __init__(self, arguments: list, stdout=subprocess.PIPE, stdout_callback: callable = None, + timeout: int = None): self.ErrorData = None self.OutputData = None self.IsSuccessful = False + self.ExitCode = -1 if arguments: try: # Merge stderr into stdout so the callback receives both @@ -44,7 +47,7 @@ def __init__(self, arguments: list, stdout=subprocess.PIPE, stdout_callback: cal if stdout == subprocess.PIPE and stdout_callback: for line in iter(process.stdout.readline, b''): stdout_callback(line.decode(encoding='utf-8')) - data, error = process.communicate() + data, error = process.communicate(timeout=timeout) self.ExitCode = process.poll() self.IsSuccessful = self.ExitCode == 0 @@ -58,6 +61,22 @@ def __init__(self, arguments: list, stdout=subprocess.PIPE, stdout_callback: cal self.ErrorData = decoded_error self.OutputData = decoded_data + # Проверяем признаки отключённого устройства + combined = (decoded_data or '') + (decoded_error or '') + if any(marker in combined for marker in ('device offline', 'device not found', + 'no devices/emulators found', + 'error: no devices')): + self.IsSuccessful = False + self.ErrorData = "Устройство недоступно (offline). Проверь подключение и попробуй снова." + + except subprocess.TimeoutExpired: + try: + process.kill() + process.communicate() + except Exception: + pass + self.ErrorData = "Превышен таймаут (%ds). Папка слишком большая или соединение медленное — попробуй ещё раз или подключись по USB." % timeout + self.IsSuccessful = False except FileNotFoundError: self.ErrorData = "Command '%s' failed! File (command) '%s' not found!" % \ (' '.join(arguments), arguments[0]) @@ -121,6 +140,9 @@ class Communicate(QObject): status_bar = QtCore.pyqtSignal(str, int) # Message, Duration notification = QtCore.pyqtSignal(MessageData) + thumbnail_ready = QtCore.pyqtSignal(str, bytes) # path, jpeg_bytes + # (model_name, avail_gb, total_gb, sd_card_path) — '' / 0.0 if unavailable + device_info_ready = QtCore.pyqtSignal(str, float, float, str) class Singleton(type): @@ -132,6 +154,55 @@ def __call__(cls, *args, **kwargs): return cls._instances[cls] +# Отдельный пул для превью — максимум 2 параллельных adb-вызова +_thumbnail_pool = QThreadPool() +_thumbnail_pool.setMaxThreadCount(2) + +# Глобальный счётчик «эпохи» — при смене папки инкрементируется, +# воркеры из старой эпохи тихо отбрасывают результат +_thumbnail_epoch = 0 + + +def thumbnail_cancel_pending(): + """Вызвать при смене папки/устройства, чтобы отменить устаревшие превью.""" + global _thumbnail_epoch + _thumbnail_epoch += 1 + + +class ThumbnailWorker(QRunnable): + """Background worker to fetch EXIF thumbnail for a single file.""" + + def __init__(self, device_id: str, path: str, mtime_iso: str): + super().__init__() + self.setAutoDelete(True) + self.device_id = device_id + self.path = path + self.mtime_iso = mtime_iso + self._epoch = _thumbnail_epoch # запоминаем эпоху при создании + + def run(self): + # Если эпоха сменилась — папка уже не актуальна, выходим тихо + if self._epoch != _thumbnail_epoch: + return + try: + from app.helpers import thumb_cache + from app.data.repositories.android_adb import FileRepository + from app.core.managers import Global + # Check disk cache first + cached = thumb_cache.get(self.device_id, self.path, self.mtime_iso) + if cached: + if self._epoch == _thumbnail_epoch: + Global.communicate.thumbnail_ready.emit(self.path, cached) + return + # Fetch from device (с таймаутом — в exec_out_head) + jpeg, err = FileRepository.fetch_exif_thumbnail(self.device_id, self.path) + if jpeg and self._epoch == _thumbnail_epoch: + thumb_cache.put(self.device_id, self.path, self.mtime_iso, jpeg) + Global.communicate.thumbnail_ready.emit(self.path, jpeg) + except Exception: + pass # тихий фолбэк — иконка останется стандартной + + # ------------------------------ # Symlink directory check helpers # ------------------------------ diff --git a/src/app/services/adb.py b/src/app/services/adb.py index 51381a3..2b4412b 100644 --- a/src/app/services/adb.py +++ b/src/app/services/adb.py @@ -89,10 +89,12 @@ def push(device_id: str, source_path: str, destination_path: str, stdout_callbac return CommonProcess(arguments=args, stdout_callback=stdout_callback) -def shell(device_id: str, args: list): +def shell(device_id: str, args: list, timeout: int = None): if RUN_AS_ROOT: - return CommonProcess([ADB_PATH, Parameter.DEVICE, device_id, Parameter.ROOT] + args) - return CommonProcess([ADB_PATH, Parameter.DEVICE, device_id, Parameter.SHELL] + args) + return CommonProcess([ADB_PATH, Parameter.DEVICE, device_id, Parameter.ROOT] + args, + timeout=timeout) + return CommonProcess([ADB_PATH, Parameter.DEVICE, device_id, Parameter.SHELL] + args, + timeout=timeout) def file_list(device_id: str, path: str): @@ -101,3 +103,21 @@ def file_list(device_id: str, path: str): def read_file(device_id: str, path: str): return CommonProcess([ADB_PATH, Parameter.DEVICE, device_id, ShellCommand.CAT, path]) + + +def exec_out_head(device_id: str, path: str, nbytes: int = 131072) -> bytes: + """Fetch first nbytes of a remote file via adb exec-out + head. Returns raw bytes.""" + import subprocess + import shlex as _shlex + try: + result = subprocess.run( + [ADB_PATH, Parameter.DEVICE, device_id, 'exec-out', + f"head -c {nbytes} {_shlex.quote(path)}"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=15 # таймаут 15с — превью по Wi-Fi не должно висеть дольше + ) + return result.stdout + except subprocess.TimeoutExpired: + return b'' + except Exception: + return b'' diff --git a/src/app/services/stream_server.py b/src/app/services/stream_server.py new file mode 100644 index 0000000..7ad030b --- /dev/null +++ b/src/app/services/stream_server.py @@ -0,0 +1,74 @@ +# ADB File Explorer +# Stream server — thin wrapper over the SHARED range-stream core. +# +# There used to be a second, independent copy of the whole Range/adb HTTP server +# right here (~200 lines). It drifted from the original: it never got the +# "filename + extension in the stream URL" fix, so QuickTime failed on its links +# with err -11828, and it announced every file as video/mp4. It also leaked one +# HTTP server per copied link — the registry stored the URL but not the server, +# and nothing ever called shutdown(). +# +# The core now lives in ONE place: adb_stream.py in the shared scripts dir (the +# same file the tray's phone-stream.sh runs). Fix it there, and the CLI, the tray +# and this app all get the fix. What stays here is only what is specific to this +# app: the per-file registry and closing everything when the window closes. + +import importlib.util +import threading + +from app.core.configurations import AppScripts + +_core = None +_core_lock = threading.Lock() + +# (device_id, remote_path) -> (url, server) +_servers = {} +_servers_lock = threading.Lock() + + +def _load_core(): + """Import adb_stream.py by path — it is a standalone script, not a package.""" + global _core + with _core_lock: + if _core is None: + spec = importlib.util.spec_from_file_location( + "adb_stream", AppScripts.ADB_STREAM + ) + if spec is None or spec.loader is None: + raise RuntimeError("Stream core not found: %s" % AppScripts.ADB_STREAM) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + _core = module + return _core + + +def start_stream(adb_path: str, device_id: str, remote_path: str) -> str: + """Start (or reuse) a localhost HTTP server streaming `remote_path`. + + Returns the URL. One server per (device_id, remote_path); the URL carries the + real filename so players can tell the container from the extension. + """ + core = _load_core() + key = (device_id, remote_path) + with _servers_lock: + existing = _servers.get(key) + if existing: + return existing[0] + url, server = core.start_server( + remote_path, serial=device_id, adb=adb_path, port=0 + ) + _servers[key] = (url, server) + return url + + +def stop_all(): + """Close every server started this session. Called when the app quits — + without this each streamed file leaves a listening socket behind.""" + with _servers_lock: + for _url, server in _servers.values(): + try: + server.shutdown() + server.server_close() + except Exception: + pass + _servers.clear()