From ade707d3a7ca9f08ecac9dc09989519bffc8c411 Mon Sep 17 00:00:00 2001 From: kapshytar Date: Sat, 27 Jun 2026 01:39:51 +0300 Subject: [PATCH 1/6] Add Stream / Copy stream link to file context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a file now offers 'Stream' and 'Copy stream link'. Both spin up a tiny localhost HTTP server (services/stream_server.py) that serves that one remote file with full HTTP Range support, reading bytes on demand via 'adb exec-out tail|head' — so a player can SEEK without downloading the whole file and nothing is written to disk. 'Stream' opens the URL in the default browser (plays mp4 with a seek bar); 'Copy stream link' puts the URL on the clipboard for VLC/QuickTime. Paths with spaces are shlex-quoted; one reusable server per file. --- src/app/gui/explorer/files.py | 37 +++++- src/app/services/stream_server.py | 211 ++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/app/services/stream_server.py diff --git a/src/app/gui/explorer/files.py b/src/app/gui/explorer/files.py index f9bf469..ec4d738 100644 --- a/src/app/gui/explorer/files.py +++ b/src/app/gui/explorer/files.py @@ -1,6 +1,7 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov import sys +import webbrowser from typing import Any from PyQt5 import QtCore, QtGui @@ -10,14 +11,15 @@ QStyleOptionViewItem, QApplication, QListView, QVBoxLayout, QLabel, QSizePolicy, QHBoxLayout, QTextEdit, \ QMainWindow -from app.core.configurations import Resources +from app.core.configurations import 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.gui.widgets.circular_progress import CircularProgress +from app.services import stream_server class FileHeaderWidget(QWidget): @@ -363,6 +365,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) @@ -470,6 +481,28 @@ 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) + + def stream_file(self): + try: + url = self._get_stream_url() + webbrowser.open(url) + except Exception as e: + print("Stream error: %s" % e, file=sys.stderr) + + 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/services/stream_server.py b/src/app/services/stream_server.py new file mode 100644 index 0000000..52c4b07 --- /dev/null +++ b/src/app/services/stream_server.py @@ -0,0 +1,211 @@ +# ADB File Explorer +# Stream server — serves a single phone file over HTTP with Range support so +# a browser or player can seek without downloading the whole file. +# +# Core adb primitive (verified): +# adb -s exec-out "tail -c + '' | head -c " +# returns exactly raw bytes starting at 0-based byte offset. +# +# Standard library only. + +import shlex +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +CHUNK = 256 * 1024 # 256 KB read/write chunk size + +# Registry: (device_id, remote_path) -> url string +# Ensures we reuse an already-running server for the same file. +_servers = {} +_servers_lock = threading.Lock() + + +def _get_file_size(adb_path: str, device_id: str, remote_path: str) -> int: + """Return total byte size of the remote file via `stat -c %s`.""" + cmd = [adb_path, "-s", device_id, "shell", "stat", "-c", "%s", + shlex.quote(remote_path)] + out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT).strip() + return int(out) + + +def _spawn_range_reader(adb_path: str, device_id: str, remote_path: str, + start: int, length: int): + """Return a Popen whose stdout emits exactly `length` bytes from `start`.""" + qpath = shlex.quote(remote_path) + inner = f"tail -c +{start + 1} {qpath} | head -c {length}" + return subprocess.Popen( + [adb_path, "-s", device_id, "exec-out", inner], + stdout=subprocess.PIPE + ) + + +def _spawn_full_reader(adb_path: str, device_id: str, remote_path: str): + """Return a Popen whose stdout is the entire remote file.""" + qpath = shlex.quote(remote_path) + return subprocess.Popen( + [adb_path, "-s", device_id, "exec-out", f"cat {qpath}"], + stdout=subprocess.PIPE + ) + + +def _parse_range(range_header: str, size: int): + """Parse a 'bytes=START-END' Range header. + + Returns (start, end) inclusive offsets, or None if absent/unsatisfiable. + Supports bytes=START-END, bytes=START-, bytes=-N (suffix). + """ + if not range_header or not range_header.startswith("bytes="): + return None + spec = range_header[len("bytes="):].strip() + if "," in spec: + spec = spec.split(",", 1)[0].strip() + if "-" not in spec: + return None + start_s, end_s = spec.split("-", 1) + start_s, end_s = start_s.strip(), end_s.strip() + try: + if start_s == "": + if not end_s: + return None + n = int(end_s) + if n <= 0: + return None + start = max(0, size - n) + end = size - 1 + else: + start = int(start_s) + end = int(end_s) if end_s else size - 1 + except ValueError: + return None + if start < 0 or start >= size: + return None + if end >= size: + end = size - 1 + if end < start: + return None + return start, end + + +def _make_handler_class(adb_path: str, device_id: str, remote_path: str, size: int): + """Return a request-handler class bound to the given stream parameters.""" + + class _Handler(BaseHTTPRequestHandler): + _adb = adb_path + _device_id = device_id + _remote_path = remote_path + _size = size + + def log_message(self, fmt, *args): # silence per-request logs + pass + + def _send_common_headers(self, content_length: int): + self.send_header("Content-Type", "video/mp4") + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(content_length)) + + @staticmethod + def _kill(proc): + try: + if proc.stdout: + proc.stdout.close() + except Exception: + pass + if proc.poll() is None: + try: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=5) + except Exception: + pass + + def _stream(self, proc): + import shutil + try: + shutil.copyfileobj(proc.stdout, self.wfile, CHUNK) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + self._kill(proc) + + def _handle(self, send_body: bool): + if self.path != "/": + self.send_error(404, "Not Found") + return + + size = self._size + range_header = self.headers.get("Range") + rng = _parse_range(range_header, size) if range_header else None + + if rng is not None: + start, end = rng + length = end - start + 1 + try: + self.send_response(206) + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self._send_common_headers(length) + self.end_headers() + except (BrokenPipeError, ConnectionResetError): + return + if not send_body: + return + proc = _spawn_range_reader(self._adb, self._device_id, + self._remote_path, start, length) + self._stream(proc) + else: + if range_header is not None: + try: + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.end_headers() + except (BrokenPipeError, ConnectionResetError): + pass + return + try: + self.send_response(200) + self._send_common_headers(size) + self.end_headers() + except (BrokenPipeError, ConnectionResetError): + return + if not send_body: + return + proc = _spawn_full_reader(self._adb, self._device_id, + self._remote_path) + self._stream(proc) + + def do_GET(self): + self._handle(send_body=True) + + def do_HEAD(self): + self._handle(send_body=False) + + return _Handler + + +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, e.g. 'http://127.0.0.1:PORT/'. + One server per (device_id, remote_path) pair; subsequent calls for the + same pair return the existing URL immediately. + """ + key = (device_id, remote_path) + with _servers_lock: + if key in _servers: + return _servers[key] + + size = _get_file_size(adb_path, device_id, remote_path) + handler_cls = _make_handler_class(adb_path, device_id, remote_path, size) + + # Bind to port 0 — the OS picks a free port. + server = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) + port = server.server_address[1] + url = f"http://127.0.0.1:{port}/" + + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + + _servers[key] = url + return url From ffcc64fe17f394c1422420d11037a855a07b7bae Mon Sep 17 00:00:00 2001 From: kapshytar Date: Sat, 27 Jun 2026 00:48:03 +0300 Subject: [PATCH 2/6] Show real download progress in external-adb mode adb pull only prints its [ N%] progress to a TTY; when its output is piped (as the app does) it stays silent until the transfer finishes, so UpDownHelper never received any progress lines and the download bar sat at 'Waiting... 0%' the whole time. Query the remote file size up front (via stat, space-safe through shlex.join) and poll the size of the file being written locally on a background thread, emitting progress to the existing callback. A 60 MB pull now reports 5..100% instead of nothing. --- src/app/data/repositories/android_adb.py | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/app/data/repositories/android_adb.py b/src/app/data/repositories/android_adb.py index 5142e14..7910790 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 @@ -116,13 +118,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) From 905ca4efab1a4eaa4f11dc135d22b1c259779717 Mon Sep 17 00:00:00 2001 From: kapshytar Date: Sat, 27 Jun 2026 07:29:40 +0300 Subject: [PATCH 3/6] open internal /sdcard by default (+SD card shortcut); show device model + free/total space in info bar --- .DS_Store | Bin 0 -> 6148 bytes src/app/core/managers.py | 3 + src/app/data/repositories/__init__.py | 16 +++ src/app/data/repositories/android_adb.py | 86 +++++++++++++++ src/app/gui/explorer/devices.py | 28 ++++- src/app/gui/explorer/files.py | 131 ++++++++++++++++++++--- src/app/gui/window.py | 66 ++++++++++++ src/app/helpers/mount.py | 88 +++++++++++++++ src/app/helpers/thumb_cache.py | 37 +++++++ src/app/helpers/tools.py | 30 +++++- src/app/services/adb.py | 13 +++ 11 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 .DS_Store create mode 100644 src/app/helpers/mount.py create mode 100644 src/app/helpers/thumb_cache.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ee2481a7fdeb8a30e0a1472c1a10a6a048d725c5 GIT binary patch literal 6148 zcmeHKJxc>Y5S>j9$*D!K60x|}M#w)n!^K*Nl~qViVj$-QKT_Xs5N-VdRyOu_w*CwS z!P3UUH@jo9$3;sCG6S=3Z)WEn_b%LIh{&`r(-u*ah#GLlT8L$Vah|X7=7*~#XBh7}1{lD|`{9i88D^)-h_*V*;An7J;Jd*U*%ENK5jo>{v8=os2 l&PuSctr%yy6>q|=As+Dt7z-8-5rOF+0V{(ps=%Kr@CA{uW+VUr literal 0 HcmV?d00001 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 7910790..ecf137c 100644 --- a/src/app/data/repositories/android_adb.py +++ b/src/app/data/repositories/android_adb.py @@ -182,6 +182,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: @@ -194,6 +211,75 @@ 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)]) + 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)]) + 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/'])]) + 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..c35d784 100644 --- a/src/app/gui/explorer/devices.py +++ b/src/app/gui/explorer/devices.py @@ -12,7 +12,7 @@ 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 @@ -166,6 +166,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 +184,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 ec4d738..71598d1 100644 --- a/src/app/gui/explorer/files.py +++ b/src/app/gui/explorer/files.py @@ -1,12 +1,14 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import os 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 +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 @@ -17,7 +19,7 @@ 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 from app.gui.widgets.circular_progress import CircularProgress from app.services import stream_server @@ -155,6 +157,9 @@ 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 + Global.communicate.thumbnail_ready.connect(self._on_thumbnail_ready) def clear(self): self.beginResetModel() @@ -164,9 +169,26 @@ def clear(self): def populate(self, files: list): self.beginResetModel() self.items.clear() + self._thumb_cache.clear() + self._thumb_pending.clear() self.items = files self.endResetModel() + def _on_thumbnail_ready(self, path: str, jpeg: bytes): + 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) @@ -215,10 +237,67 @@ 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 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) + QThreadPool.globalInstance().start(worker) return QPixmap(self.icon_path(index)).scaled(32, 32, Qt.KeepAspectRatio) return QVariant() +class DeviceInfoBar(QWidget): + """Thin bar above file list showing device model, free space, and SD-card shortcut.""" + + def __init__(self, parent=None): + super(DeviceInfoBar, self).__init__(parent) + self._sd_path = '' + 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.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)) + + 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 @@ -230,6 +309,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) @@ -406,21 +488,44 @@ 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)) diff --git a/src/app/gui/window.py b/src/app/gui/window.py index 0c1cd74..ea7f5e8 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,23 @@ 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.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 +38,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 +85,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, 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..61836fb 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 from PyQt5.QtWidgets import QWidget from app.data.models import MessageData @@ -121,6 +121,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 +135,31 @@ def __call__(cls, *args, **kwargs): return cls._instances[cls] +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.device_id = device_id + self.path = path + self.mtime_iso = mtime_iso + + def run(self): + 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: + Global.communicate.thumbnail_ready.emit(self.path, cached) + return + # Fetch from device + jpeg, err = FileRepository.fetch_exif_thumbnail(self.device_id, self.path) + if jpeg: + thumb_cache.put(self.device_id, self.path, self.mtime_iso, jpeg) + Global.communicate.thumbnail_ready.emit(self.path, jpeg) + + # ------------------------------ # Symlink directory check helpers # ------------------------------ diff --git a/src/app/services/adb.py b/src/app/services/adb.py index 51381a3..8823dd5 100644 --- a/src/app/services/adb.py +++ b/src/app/services/adb.py @@ -101,3 +101,16 @@ 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 + try: + result = subprocess.run( + [ADB_PATH, Parameter.DEVICE, device_id, 'exec-out', f"head -c {nbytes} '{path}'"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + return result.stdout + except Exception: + return b'' From 0132c63c47afc6f3ba23591f5bc7903191053b93 Mon Sep 17 00:00:00 2001 From: kapshytar Date: Sat, 27 Jun 2026 14:35:17 +0300 Subject: [PATCH 4/6] =?UTF-8?q?stream:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B0=D0=B2=D1=82=D0=BE-=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D1=80=D1=82=20(phone-stream.sh),=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BC=20=D0=B2=20IINA,=20=D0=B4=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BB=D0=B8=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=3D=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BC,=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0,=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE-connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/data/repositories/android_adb.py | 16 +++- src/app/gui/explorer/devices.py | 15 +++- src/app/gui/explorer/files.py | 105 ++++++++++++++++++++--- src/app/helpers/tools.py | 73 ++++++++++++---- src/app/services/adb.py | 13 ++- 5 files changed, 188 insertions(+), 34 deletions(-) diff --git a/src/app/data/repositories/android_adb.py b/src/app/data/repositories/android_adb.py index ecf137c..a40d2e9 100644 --- a/src/app/data/repositories/android_adb.py +++ b/src/app/data/repositories/android_adb.py @@ -37,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(): @@ -44,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 @@ -65,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) diff --git a/src/app/gui/explorer/devices.py b/src/app/gui/explorer/devices.py index c35d784..90e1e93 100644 --- a/src/app/gui/explorer/devices.py +++ b/src/app/gui/explorer/devices.py @@ -1,5 +1,7 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov +import os +import subprocess from typing import Any from PyQt5 import QtGui, QtCore @@ -16,6 +18,17 @@ from app.helpers.tools import AsyncRepositoryWorker, read_string_from_file from app.gui.widgets.circular_progress import CircularProgress +_TRANSPORT_SCRIPT = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-transport.sh') + + +def _devices_with_auto_connect(): + """Обёртка для DeviceRepository.devices с авто-подключением по Wi-Fi-adb через phone-transport.sh.""" + try: + subprocess.run(['bash', _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 +136,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 ) diff --git a/src/app/gui/explorer/files.py b/src/app/gui/explorer/files.py index 71598d1..041fa6a 100644 --- a/src/app/gui/explorer/files.py +++ b/src/app/gui/explorer/files.py @@ -1,13 +1,14 @@ # 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, QUrl, QThreadPool +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, \ @@ -19,7 +20,8 @@ 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, ThumbnailWorker +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 @@ -153,12 +155,16 @@ 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): @@ -167,11 +173,14 @@ 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): @@ -238,7 +247,7 @@ def data(self, index: QModelIndex, role: int = ...) -> Any: return self.items[index.row()].name elif role == Qt.DecorationRole: file = self.items[index.row()] - if file.name.lower().endswith(('.jpg', '.jpeg', '.png', '.heic')): + 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: @@ -247,17 +256,47 @@ def data(self, index: QModelIndex, role: int = ...) -> Any: 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) - QThreadPool.globalInstance().start(worker) + # Используем отдельный пул с лимитом 2 потока вместо globalInstance + _thumbnail_pool.start(worker) return QPixmap(self.icon_path(index)).scaled(32, 32, Qt.KeepAspectRatio) return QVariant() +_TRANSPORT_SCRIPT = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-transport.sh') + +_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, and SD-card shortcut.""" + """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) @@ -267,6 +306,10 @@ def __init__(self, parent=None): 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) @@ -289,6 +332,19 @@ def _update(self, model: str, avail: float, total: float, sd_path: str): 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 and self._transport_worker.isRunning(): + return + self._transport_worker = _TransportWorker(self) + self._transport_worker.result.connect(self._on_transport_result) + self._transport_worker.start() + + def _on_transport_result(self, label: str): + self.channel_label.setText('Канал: ' + label) def _go_sd(self): if not self._sd_path: @@ -358,6 +414,8 @@ def files(self): def update(self): super(FileExplorerWidget, self).update() + # Отменяем устаревшие превью при переходе в другую папку + thumbnail_cancel_pending() worker = AsyncRepositoryWorker( name="Files", worker_id=self.FILES_WORKER_ID, @@ -388,17 +446,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() @@ -412,7 +474,13 @@ 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() + if not item.isdir 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): @@ -594,12 +662,27 @@ def _get_stream_url(self): 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: - url = self._get_stream_url() - webbrowser.open(url) + if not self.file: + return + remote_path = self.file.path + stream_script = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-stream.sh') + subprocess.Popen(['bash', stream_script, remote_path]) 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: diff --git a/src/app/helpers/tools.py b/src/app/helpers/tools.py index 61836fb..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, QRunnable +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]) @@ -135,29 +154,53 @@ 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): - 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: - Global.communicate.thumbnail_ready.emit(self.path, cached) + # Если эпоха сменилась — папка уже не актуальна, выходим тихо + if self._epoch != _thumbnail_epoch: return - # Fetch from device - jpeg, err = FileRepository.fetch_exif_thumbnail(self.device_id, self.path) - if jpeg: - thumb_cache.put(self.device_id, self.path, self.mtime_iso, jpeg) - Global.communicate.thumbnail_ready.emit(self.path, jpeg) + 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 # тихий фолбэк — иконка останется стандартной # ------------------------------ diff --git a/src/app/services/adb.py b/src/app/services/adb.py index 8823dd5..3341f30 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): @@ -109,8 +111,11 @@ def exec_out_head(device_id: str, path: str, nbytes: int = 131072) -> bytes: try: result = subprocess.run( [ADB_PATH, Parameter.DEVICE, device_id, 'exec-out', f"head -c {nbytes} '{path}'"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=15 # таймаут 15с — превью по Wi-Fi не должно висеть дольше ) return result.stdout + except subprocess.TimeoutExpired: + return b'' except Exception: return b'' From 4136261e18cdc162f777ac543ee3c4e2861a6657 Mon Sep 17 00:00:00 2001 From: kapshytar Date: Sat, 27 Jun 2026 21:02:41 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E-=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B:=20delete()=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88?= =?UTF-8?q?=D0=BE=D1=82=20(=D0=BD=D0=B5=20=D1=82=D0=B5=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D1=8B),=20stream=5Ffile=20detached+DEVNULL,=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=D1=8B=20=D0=B2=20Sto?= =?UTF-8?q?rageRepository/stream=5Fserver,=20=5FTransportWorker=20lifecycl?= =?UTF-8?q?e,=20=D1=81=D0=B8=D0=BC=D0=BB=D0=B8=D0=BD=D0=BA-=D0=BF=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D1=81=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D1=81=D1=8F,=20=D1=8D=D0=BF=D0=BE=D1=85?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E,=20shlex.quote=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D1=8C,=20=D0=BF=D1=83=D1=82=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2=20=D0=B2=20configura?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/core/configurations.py | 20 ++++++++ src/app/data/repositories/android_adb.py | 7 +-- src/app/gui/explorer/devices.py | 8 +--- src/app/gui/explorer/files.py | 61 ++++++++++++++++++++---- src/app/services/adb.py | 4 +- src/app/services/stream_server.py | 6 ++- 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/app/core/configurations.py b/src/app/core/configurations.py index 5e1e06b..1f481d3 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,25 @@ 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') + + class Resources: __metaclass__ = Singleton diff --git a/src/app/data/repositories/android_adb.py b/src/app/data/repositories/android_adb.py index a40d2e9..af721da 100644 --- a/src/app/data/repositories/android_adb.py +++ b/src/app/data/repositories/android_adb.py @@ -228,7 +228,8 @@ class StorageRepository: 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)]) + 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: @@ -245,7 +246,7 @@ def get_disk_info(device_id: str, path: str = '/sdcard') -> dict: for flag in ['-k', '']: try: cmd_parts = ['df'] + ([flag] if flag else []) + [path] - response = adb.shell(device_id, [shlex.join(cmd_parts)]) + 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()] @@ -280,7 +281,7 @@ 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/'])]) + 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()): diff --git a/src/app/gui/explorer/devices.py b/src/app/gui/explorer/devices.py index 90e1e93..c75c544 100644 --- a/src/app/gui/explorer/devices.py +++ b/src/app/gui/explorer/devices.py @@ -1,6 +1,5 @@ # ADB File Explorer # Copyright (C) 2022 Azat Aldeshov -import os import subprocess from typing import Any @@ -10,7 +9,7 @@ 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 @@ -18,13 +17,10 @@ from app.helpers.tools import AsyncRepositoryWorker, read_string_from_file from app.gui.widgets.circular_progress import CircularProgress -_TRANSPORT_SCRIPT = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-transport.sh') - - def _devices_with_auto_connect(): """Обёртка для DeviceRepository.devices с авто-подключением по Wi-Fi-adb через phone-transport.sh.""" try: - subprocess.run(['bash', _TRANSPORT_SCRIPT], capture_output=True, timeout=6) + subprocess.run(['bash', AppScripts.TRANSPORT_SCRIPT], capture_output=True, timeout=6) except Exception: pass return DeviceRepository.devices() diff --git a/src/app/gui/explorer/files.py b/src/app/gui/explorer/files.py index 041fa6a..0dcb667 100644 --- a/src/app/gui/explorer/files.py +++ b/src/app/gui/explorer/files.py @@ -14,7 +14,7 @@ QStyleOptionViewItem, QApplication, QListView, QVBoxLayout, QLabel, QSizePolicy, QHBoxLayout, QTextEdit, \ QMainWindow -from app.core.configurations import Resources, Settings +from app.core.configurations import AppScripts, Resources, Settings from app.core.main import Adb from app.core.managers import Global, ADBManager from app.data.models import FileType, MessageData, MessageType @@ -184,6 +184,9 @@ def populate(self, files: list): 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(): @@ -262,7 +265,7 @@ def data(self, index: QModelIndex, role: int = ...) -> Any: return QVariant() -_TRANSPORT_SCRIPT = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-transport.sh') +_TRANSPORT_SCRIPT = AppScripts.TRANSPORT_SCRIPT _TRANSPORT_KIND_LABELS = { 'usb': 'USB', @@ -337,12 +340,31 @@ def _update(self, model: str, avail: float, total: float, sd_path: str): self._start_transport_query() def _start_transport_query(self): - if self._transport_worker and self._transport_worker.isRunning(): - return + 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) @@ -476,7 +498,10 @@ def eventFilter(self, obj: 'QObject', event: 'QEvent') -> bool: def open(self, index: QModelIndex = ...): item = self.model.items[index.row()] ext = os.path.splitext(item.name)[1].lower() - if not item.isdir and ext in self._VIDEO_EXTENSIONS: + # 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 @@ -596,7 +621,10 @@ def open_response(data, error): 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', @@ -605,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( @@ -671,8 +699,23 @@ def stream_file(self): if not self.file: return remote_path = self.file.path - stream_script = os.path.expanduser('~/PhoneAsExtStorage/adbfs-rootless/phone-stream.sh') - subprocess.Popen(['bash', stream_script, remote_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( diff --git a/src/app/services/adb.py b/src/app/services/adb.py index 3341f30..2b4412b 100644 --- a/src/app/services/adb.py +++ b/src/app/services/adb.py @@ -108,9 +108,11 @@ def read_file(device_id: str, path: str): 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} '{path}'"], + [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 не должно висеть дольше ) diff --git a/src/app/services/stream_server.py b/src/app/services/stream_server.py index 52c4b07..28b1a61 100644 --- a/src/app/services/stream_server.py +++ b/src/app/services/stream_server.py @@ -25,7 +25,11 @@ def _get_file_size(adb_path: str, device_id: str, remote_path: str) -> int: """Return total byte size of the remote file via `stat -c %s`.""" cmd = [adb_path, "-s", device_id, "shell", "stat", "-c", "%s", shlex.quote(remote_path)] - out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT).strip() + try: + out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, + timeout=10).strip() + except subprocess.TimeoutExpired: + raise RuntimeError("Timed out fetching file size for: %s" % remote_path) return int(out) From 38f5b1773d08479407e88924b740a1984423f9ff Mon Sep 17 00:00:00 2001 From: kapshytar Date: Mon, 27 Jul 2026 01:34:32 +0300 Subject: [PATCH 6/6] =?UTF-8?q?stream:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B5=20=D1=8F=D0=B4=D1=80?= =?UTF-8?q?=D0=BE=20adb=5Fstream.py=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Здесь лежала независимая копия всего Range/adb HTTP-сервера (~200 строк). Она разошлась с оригиналом: не получила фикс «имя файла с расширением в URL стрима» (QuickTime отваливался с err -11828), отдавала любой файл как video/mp4, и текла — реестр хранил URL, но не сам сервер, а shutdown() не звался нигде, так что каждая скопированная ссылка оставляла слушающий сокет до выхода из приложения. Ядро теперь одно — adb_stream.py в общей папке скриптов (его же гоняет phone-stream.sh из трея). Здесь осталось только своё: реестр по (device, path) и закрытие серверов в closeEvent. 215 строк → 74. Проверено на телефоне: URL несёт имя с расширением, повторный запрос того же файла переиспользует сервер, разные файлы получают разные порты, range-probe.sh 14 PASS/0 FAIL, после stop_all оба порта закрыты. Co-Authored-By: Claude Fable 5 --- src/app/core/configurations.py | 2 + src/app/gui/window.py | 8 + src/app/services/stream_server.py | 247 +++++++----------------------- 3 files changed, 63 insertions(+), 194 deletions(-) diff --git a/src/app/core/configurations.py b/src/app/core/configurations.py index 1f481d3..478603b 100644 --- a/src/app/core/configurations.py +++ b/src/app/core/configurations.py @@ -102,6 +102,8 @@ 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: diff --git a/src/app/gui/window.py b/src/app/gui/window.py index ea7f5e8..4f4a2aa 100644 --- a/src/app/gui/window.py +++ b/src/app/gui/window.py @@ -13,6 +13,7 @@ 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 @@ -245,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/services/stream_server.py b/src/app/services/stream_server.py index 28b1a61..7ad030b 100644 --- a/src/app/services/stream_server.py +++ b/src/app/services/stream_server.py @@ -1,215 +1,74 @@ # ADB File Explorer -# Stream server — serves a single phone file over HTTP with Range support so -# a browser or player can seek without downloading the whole file. +# Stream server — thin wrapper over the SHARED range-stream core. # -# Core adb primitive (verified): -# adb -s exec-out "tail -c + '' | head -c " -# returns exactly raw bytes starting at 0-based byte offset. +# 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(). # -# Standard library only. +# 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 shlex -import subprocess +import importlib.util import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -CHUNK = 256 * 1024 # 256 KB read/write chunk size +from app.core.configurations import AppScripts -# Registry: (device_id, remote_path) -> url string -# Ensures we reuse an already-running server for the same file. +_core = None +_core_lock = threading.Lock() + +# (device_id, remote_path) -> (url, server) _servers = {} _servers_lock = threading.Lock() -def _get_file_size(adb_path: str, device_id: str, remote_path: str) -> int: - """Return total byte size of the remote file via `stat -c %s`.""" - cmd = [adb_path, "-s", device_id, "shell", "stat", "-c", "%s", - shlex.quote(remote_path)] - try: - out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, - timeout=10).strip() - except subprocess.TimeoutExpired: - raise RuntimeError("Timed out fetching file size for: %s" % remote_path) - return int(out) - - -def _spawn_range_reader(adb_path: str, device_id: str, remote_path: str, - start: int, length: int): - """Return a Popen whose stdout emits exactly `length` bytes from `start`.""" - qpath = shlex.quote(remote_path) - inner = f"tail -c +{start + 1} {qpath} | head -c {length}" - return subprocess.Popen( - [adb_path, "-s", device_id, "exec-out", inner], - stdout=subprocess.PIPE - ) - - -def _spawn_full_reader(adb_path: str, device_id: str, remote_path: str): - """Return a Popen whose stdout is the entire remote file.""" - qpath = shlex.quote(remote_path) - return subprocess.Popen( - [adb_path, "-s", device_id, "exec-out", f"cat {qpath}"], - stdout=subprocess.PIPE - ) - - -def _parse_range(range_header: str, size: int): - """Parse a 'bytes=START-END' Range header. - - Returns (start, end) inclusive offsets, or None if absent/unsatisfiable. - Supports bytes=START-END, bytes=START-, bytes=-N (suffix). - """ - if not range_header or not range_header.startswith("bytes="): - return None - spec = range_header[len("bytes="):].strip() - if "," in spec: - spec = spec.split(",", 1)[0].strip() - if "-" not in spec: - return None - start_s, end_s = spec.split("-", 1) - start_s, end_s = start_s.strip(), end_s.strip() - try: - if start_s == "": - if not end_s: - return None - n = int(end_s) - if n <= 0: - return None - start = max(0, size - n) - end = size - 1 - else: - start = int(start_s) - end = int(end_s) if end_s else size - 1 - except ValueError: - return None - if start < 0 or start >= size: - return None - if end >= size: - end = size - 1 - if end < start: - return None - return start, end - - -def _make_handler_class(adb_path: str, device_id: str, remote_path: str, size: int): - """Return a request-handler class bound to the given stream parameters.""" - - class _Handler(BaseHTTPRequestHandler): - _adb = adb_path - _device_id = device_id - _remote_path = remote_path - _size = size - - def log_message(self, fmt, *args): # silence per-request logs - pass - - def _send_common_headers(self, content_length: int): - self.send_header("Content-Type", "video/mp4") - self.send_header("Accept-Ranges", "bytes") - self.send_header("Content-Length", str(content_length)) - - @staticmethod - def _kill(proc): - try: - if proc.stdout: - proc.stdout.close() - except Exception: - pass - if proc.poll() is None: - try: - proc.kill() - except Exception: - pass - try: - proc.wait(timeout=5) - except Exception: - pass - - def _stream(self, proc): - import shutil - try: - shutil.copyfileobj(proc.stdout, self.wfile, CHUNK) - except (BrokenPipeError, ConnectionResetError): - pass - finally: - self._kill(proc) - - def _handle(self, send_body: bool): - if self.path != "/": - self.send_error(404, "Not Found") - return - - size = self._size - range_header = self.headers.get("Range") - rng = _parse_range(range_header, size) if range_header else None - - if rng is not None: - start, end = rng - length = end - start + 1 - try: - self.send_response(206) - self.send_header("Content-Range", f"bytes {start}-{end}/{size}") - self._send_common_headers(length) - self.end_headers() - except (BrokenPipeError, ConnectionResetError): - return - if not send_body: - return - proc = _spawn_range_reader(self._adb, self._device_id, - self._remote_path, start, length) - self._stream(proc) - else: - if range_header is not None: - try: - self.send_response(416) - self.send_header("Content-Range", f"bytes */{size}") - self.end_headers() - except (BrokenPipeError, ConnectionResetError): - pass - return - try: - self.send_response(200) - self._send_common_headers(size) - self.end_headers() - except (BrokenPipeError, ConnectionResetError): - return - if not send_body: - return - proc = _spawn_full_reader(self._adb, self._device_id, - self._remote_path) - self._stream(proc) - - def do_GET(self): - self._handle(send_body=True) - - def do_HEAD(self): - self._handle(send_body=False) - - return _Handler +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, e.g. 'http://127.0.0.1:PORT/'. - One server per (device_id, remote_path) pair; subsequent calls for the - same pair return the existing URL immediately. + 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: - if key in _servers: - return _servers[key] - - size = _get_file_size(adb_path, device_id, remote_path) - handler_cls = _make_handler_class(adb_path, device_id, remote_path, size) - - # Bind to port 0 — the OS picks a free port. - server = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) - port = server.server_address[1] - url = f"http://127.0.0.1:{port}/" + 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 - t = threading.Thread(target=server.serve_forever, daemon=True) - t.start() - _servers[key] = url - 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()