adbsshdeck 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeviceDeck contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.2
2
+ Name: adbsshdeck
3
+ Version: 0.1.1
4
+ Summary: Desktop workspace for Android: ADB and SSH terminals, serial, file access, and USB screen control (DeviceDeck).
5
+ Author: DeviceDeck contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://nvnkennedy.github.io/Device_Deck/
8
+ Project-URL: Repository, https://github.com/nvnkennedy/Device_Deck
9
+ Project-URL: Documentation, https://github.com/nvnkennedy/Device_Deck#readme
10
+ Project-URL: PyPI, https://pypi.org/project/adbsshdeck/
11
+ Keywords: android,adb,ssh,serial,scrcpy,pyqt5,sftp,desktop,screen-mirroring
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: X11 Applications :: Qt
14
+ Classifier: Environment :: Win32 (MS Windows)
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyQt5<6.0,>=5.15.11
27
+ Requires-Dist: pyserial<4.0,>=3.5
28
+ Requires-Dist: paramiko<5.0,>=3.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7.0; extra == "dev"
31
+ Provides-Extra: build
32
+ Requires-Dist: pyinstaller<7.0,>=6.0; extra == "build"
33
+
34
+ # DeviceDeck (PyQt5)
35
+
36
+ Desktop workspace for Android debugging: ADB file transfer, multi-session **SSH**, **serial**, USB **screen** forwarding, and on-device files — in one app.
37
+
38
+ | | |
39
+ |--|--|
40
+ | **PyPI package** | **`adbsshdeck`** (ADB + SSH + workspace “deck”, including screen control) |
41
+ | **Run after install** | **`adbsshdeck`** (also in `Scripts` on Windows) |
42
+ | **Python import** | `devicedeck` (unchanged) |
43
+ | **Website** | [nvnkennedy.github.io/Device_Deck](https://nvnkennedy.github.io/Device_Deck/) |
44
+ | **PyPI** | [pypi.org/project/adbsshdeck](https://pypi.org/project/adbsshdeck/) |
45
+
46
+ The name **`devicedeck`** on PyPI was already taken; **`adbsshdeck`** is this project’s distribution name.
47
+
48
+ ## Prerequisites
49
+
50
+ - Python 3.9+
51
+ - **ADB** and a **USB display-forwarding** tool on `PATH`, or paths in **File → Preferences**
52
+ - **`ssh` on PATH** if you use SSH terminal sessions
53
+ - **Serial/COM** hardware if you use serial tabs (pyserial is a dependency)
54
+
55
+ ## Install (recommended)
56
+
57
+ From [PyPI](https://pypi.org/project/adbsshdeck/):
58
+
59
+ ```bash
60
+ python -m pip install --upgrade pip
61
+ python -m pip install adbsshdeck
62
+ ```
63
+
64
+ Then:
65
+
66
+ ```bash
67
+ adbsshdeck
68
+ ```
69
+
70
+ Same as: `python -m devicedeck` (import package is still `devicedeck`).
71
+
72
+ This repository is **source-only** on GitHub — users install the **published package** with pip.
73
+
74
+ ## Install from a git clone (development)
75
+
76
+ ```bash
77
+ git clone https://github.com/nvnkennedy/Device_Deck.git
78
+ cd Device_Deck
79
+ pip install -e ".[dev]"
80
+ ```
81
+
82
+ Or:
83
+
84
+ ```bash
85
+ pip install "git+https://github.com/nvnkennedy/Device_Deck.git"
86
+ ```
87
+
88
+ `main.py` is a thin launcher for local development only.
89
+
90
+ ## Publishing to PyPI
91
+
92
+ See **[docs/PYPI_PUBLISH.md](docs/PYPI_PUBLISH.md)** (build wheel/sdist, API token, optional GitHub Actions).
93
+
94
+ ## Website (GitHub Pages)
95
+
96
+ Static site under `site/` — **Install** instructions for end users. Deployed by `.github/workflows/pages.yml` when you push changes under `site/`.
97
+
98
+ ## Windows standalone executable (optional, maintainers only)
99
+
100
+ For machines **without** Python, you can build a **PyInstaller** folder or installer **locally** — outputs are **not** committed to this repo.
101
+
102
+ ```powershell
103
+ pip install -e ".[build]"
104
+ powershell -ExecutionPolicy Bypass -File scripts\build_windows_exe.ps1
105
+ ```
106
+
107
+ See `DeviceDeck.spec`. ADB and display-forwarding tools are **not** bundled.
108
+
109
+ ## Project layout
110
+
111
+ - `pyproject.toml` — metadata; console entry **`adbsshdeck`**
112
+ - `devicedeck/` — application code (import name)
113
+ - `site/` — GitHub Pages (home + install + PyPI links)
114
+ - `scripts/` — `build_windows_exe.ps1`, `export_app_icon.py`
115
+ - `tests/` — pytest
116
+ - `DeviceDeck.spec` — PyInstaller (optional)
117
+
118
+ ## Build wheel / sdist
119
+
120
+ ```bash
121
+ pip install build
122
+ python -m build
123
+ ```
124
+
125
+ Artifacts: `dist/adbsshdeck-*.whl` and `dist/*.tar.gz`.
126
+
127
+ ## Testing
128
+
129
+ ```bash
130
+ python -m pytest tests/ -q
131
+ ```
132
+
133
+ ## Notes
134
+
135
+ - Settings: `~/.devicedeck.json`
136
+ - License: MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,29 @@
1
+ devicedeck/__init__.py,sha256=Cwb3P1gD2rAtHfcyjWR97oCHXtl_cVp11bnhhheUJH4,137
2
+ devicedeck/__main__.py,sha256=jJMizmtrs4RRW6hHriblxX87GGrCZ8u5bXYnY8lVvAg,65
3
+ devicedeck/app.py,sha256=gipuf5TRNqjQ1eKJW4klLfQP1g-47AFcE5-r-BjuyUU,1399
4
+ devicedeck/config.py,sha256=GnxwCyLSvmsNAfxkQK9aSoCC0kPH7c4NXP-MZTgusjw,5404
5
+ devicedeck/session.py,sha256=WduDszs3lGzsk8yVBhdj5Mwz52dIz2whWLKFCXE2Rx4,1426
6
+ devicedeck/services/__init__.py,sha256=-pvWpwx6VTZC3aVtyZfKSnNlNDaw_W3YPMDDtXGKCC4,27
7
+ devicedeck/services/adb_devices.py,sha256=rVu6bGxr57Jbel06hijlSxbDZgp4RtyxVdb83PV3tZ8,2536
8
+ devicedeck/services/commands.py,sha256=WVl5ffoW38ZNi6TGWNSNckvYSiUPCz0YWjmNyRMB0I8,3044
9
+ devicedeck/services/remote_clients.py,sha256=_tDHAEHFXsceKkSefdDlWbnn3W24qa0EedvYq31AEU4,2189
10
+ devicedeck/ui/__init__.py,sha256=y7PPGvLSjPcvNLfoUdhyQhhwnVza925Xgetg9GG9Oz0,14
11
+ devicedeck/ui/app_icon.py,sha256=aTJnCPn93nNMiH9ttrMDGEt08MO0921S8rNAX2lAo3s,1418
12
+ devicedeck/ui/combo_utils.py,sha256=uSYxgFY1ZCrKWHYirA2GJmFk_8egAW7aOL4sFGiFUyk,1779
13
+ devicedeck/ui/first_run_dialog.py,sha256=YPBWBjIIntFMvANXsdN0HIrSsgCBqsjSNO0rOgBaFLI,3670
14
+ devicedeck/ui/icon_utils.py,sha256=5Q-6lFIerolMnoY4BxujQj_H8Sqsofr8wlB387xHwiI,4630
15
+ devicedeck/ui/main_window.py,sha256=LqkfAFwsbEjX1mn01oCt1wSIJl4PLKm-zyEOe0HSmqk,30515
16
+ devicedeck/ui/preferences_dialog.py,sha256=NdC3qGo2P22jwNQGnJOSvUqkHh5khh4-1vljfPAfWiU,4333
17
+ devicedeck/ui/session_login_dialog.py,sha256=9dZ-CjOU7BmUwiXhuKI_uUeg-iYit8mtb4kcEefk9J0,30828
18
+ devicedeck/ui/styles.py,sha256=f4QNpDBY7ch5f0p2RCtEc4RgLIiQRpVybZHl0bVsl8Q,30904
19
+ devicedeck/ui/win_scrcpy_hotkey.py,sha256=1WLlM5L_x3pi4RSA5kt9DibTC97UxO4GCfmH6fPhHqo,2287
20
+ devicedeck/ui/tabs/__init__.py,sha256=Bo61o8qAdbFDOS19L6BKmezb7pvVIqAwx3qb_n3v2HY,21
21
+ devicedeck/ui/tabs/file_explorer_tab.py,sha256=dh_y88eF2UxMT90ty5v9UXizvLC8HEj_OK38La9WwF4,151211
22
+ devicedeck/ui/tabs/scrcpy_tab.py,sha256=auJK-r7m91bG7k4-ZwYEjZgfNqCss0RpGyqzCSQcxYU,30871
23
+ devicedeck/ui/tabs/terminal_tab.py,sha256=0kXFvlHb9l3XkBzpGpUgaeUeZ89Pvkw8bPZsBGjkcbk,47928
24
+ adbsshdeck-0.1.1.dist-info/LICENSE,sha256=_SWWCpa7LHxGYXdl3gQFlvj8SAf8H4mNw21HCkwQyas,1101
25
+ adbsshdeck-0.1.1.dist-info/METADATA,sha256=fo6iu9HXse74aokfYd9tsJmdFKcTYdTwohRtM5dNPSY,4487
26
+ adbsshdeck-0.1.1.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
27
+ adbsshdeck-0.1.1.dist-info/entry_points.txt,sha256=9jRm-947unZlvUXPBXkaT3pxVj-2wagtOs0DpIWYeu4,51
28
+ adbsshdeck-0.1.1.dist-info/top_level.txt,sha256=jiQAATACR-5dTUdD_P2g-pZ0mpJ8K-P_A5StUrRzd74,11
29
+ adbsshdeck-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adbsshdeck = devicedeck.app:main
@@ -0,0 +1 @@
1
+ devicedeck
devicedeck/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # Application display name (window title, About, scrcpy default window title, etc.).
2
+ APP_TITLE = "DeviceDeck"
3
+
4
+ __version__ = "0.1.1"
devicedeck/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
devicedeck/app.py ADDED
@@ -0,0 +1,45 @@
1
+ import sys
2
+
3
+ from PyQt5.QtCore import Qt
4
+ from PyQt5.QtWidgets import QApplication, QStyleFactory
5
+
6
+ from . import APP_TITLE
7
+ from .config import AppConfig, has_existing_config_file
8
+ from .ui.app_icon import create_app_icon
9
+ from .ui.main_window import MainWindow
10
+
11
+
12
+ def _set_windows_app_user_model_id() -> None:
13
+ """So the taskbar uses our window icon instead of the generic Python icon."""
14
+ if sys.platform != "win32":
15
+ return
16
+ try:
17
+ import ctypes
18
+
19
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("DeviceDeck.DeviceDeck.Application.1")
20
+ except Exception:
21
+ pass
22
+
23
+
24
+ def main():
25
+ _set_windows_app_user_model_id()
26
+ try:
27
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
28
+ except AttributeError:
29
+ pass
30
+ app = QApplication(sys.argv)
31
+ app.setWindowIcon(create_app_icon())
32
+ fusion = QStyleFactory.create("Fusion")
33
+ if fusion is not None:
34
+ app.setStyle(fusion)
35
+ app.setApplicationName(APP_TITLE)
36
+ # Blinking text caret (ms). QApplication provides this in Qt5; ignore if unavailable.
37
+ try:
38
+ QApplication.setCursorFlashTime(530)
39
+ except AttributeError:
40
+ pass
41
+ fresh_config = not has_existing_config_file()
42
+ config = AppConfig.load()
43
+ window = MainWindow(config, first_launch=fresh_config)
44
+ window.show()
45
+ sys.exit(app.exec_())
devicedeck/config.py ADDED
@@ -0,0 +1,130 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from dataclasses import asdict, dataclass, field, fields
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+
9
+ # Primary settings file; legacy `~/.adb_explorer_pro.json` is still read and removed after first save.
10
+ CONFIG_PATH = Path.home() / ".devicedeck.json"
11
+ _LEGACY_CONFIG_PATH = Path.home() / ".adb_explorer_pro.json"
12
+
13
+
14
+ def has_existing_config_file() -> bool:
15
+ return CONFIG_PATH.exists() or _LEGACY_CONFIG_PATH.exists()
16
+
17
+
18
+ def _sanitize_bookmark(bookmark: Dict[str, Any]) -> Dict[str, Any]:
19
+ """Drop sensitive fields before keeping bookmark data in config."""
20
+ cleaned = dict(bookmark)
21
+ for key in ("ssh_password", "sftp_password", "ftp_password"):
22
+ cleaned.pop(key, None)
23
+ return cleaned
24
+
25
+
26
+ @dataclass
27
+ class AppConfig:
28
+ # Empty on first run; resolved at runtime to "adb"/"scrcpy" when unset.
29
+ adb_path: str = ""
30
+ scrcpy_path: str = ""
31
+ dark_theme: bool = False
32
+ # When True, dock scrcpy into the Screen Control tab (Windows). Default False: separate window keeps
33
+ # reliable touch/swipe (embedded HWND reparenting often breaks input).
34
+ embed_scrcpy_mirror: bool = False
35
+ # Legacy mirror of embed checkbox; kept for older configs (embed off => opt_out true).
36
+ embed_scrcpy_mirror_opt_out: bool = True
37
+ default_ssh_host: str = ""
38
+ default_serial_port: str = "COM3"
39
+ default_serial_baud: str = "115200"
40
+ # Optional SSH: sent to the active terminal when using Commands → SSH (after you open an SSH tab).
41
+ ssh_mount_command: str = ""
42
+ # List of {"label": str, "command": str} — customizable in Preferences.
43
+ ssh_quick_commands: List[Dict[str, str]] = field(default_factory=list)
44
+ # Saved sessions (WinSCP/Moba-style): list of dicts with kind, name, host, user, etc.
45
+ session_bookmarks: List[Dict[str, Any]] = field(default_factory=list)
46
+ # Find files dialog: recent folder paths per side (local vs remote search)
47
+ find_folder_history_local: List[str] = field(default_factory=list)
48
+ find_folder_history_remote: List[str] = field(default_factory=list)
49
+
50
+ @classmethod
51
+ def load(cls) -> "AppConfig":
52
+ if CONFIG_PATH.exists():
53
+ read_path = CONFIG_PATH
54
+ elif _LEGACY_CONFIG_PATH.exists():
55
+ read_path = _LEGACY_CONFIG_PATH
56
+ else:
57
+ return cls()
58
+ known = {f.name for f in fields(cls)}
59
+ try:
60
+ raw = json.loads(read_path.read_text(encoding="utf-8"))
61
+ defaults = asdict(cls())
62
+ if isinstance(raw, dict):
63
+ for key, value in raw.items():
64
+ if key in known:
65
+ defaults[key] = value
66
+ if not isinstance(defaults.get("session_bookmarks"), list):
67
+ defaults["session_bookmarks"] = []
68
+ else:
69
+ defaults["session_bookmarks"] = [
70
+ _sanitize_bookmark(x)
71
+ for x in defaults["session_bookmarks"]
72
+ if isinstance(x, dict)
73
+ ]
74
+ qc = defaults.get("ssh_quick_commands")
75
+ if not isinstance(qc, list):
76
+ defaults["ssh_quick_commands"] = []
77
+ else:
78
+ defaults["ssh_quick_commands"] = [
79
+ {"label": str(x.get("label", "")), "command": str(x.get("command", ""))}
80
+ for x in qc
81
+ if isinstance(x, dict) and (x.get("label") or x.get("command"))
82
+ ]
83
+ for key in ("find_folder_history_local", "find_folder_history_remote"):
84
+ lst = defaults.get(key)
85
+ if not isinstance(lst, list):
86
+ defaults[key] = []
87
+ else:
88
+ defaults[key] = [str(x).strip() for x in lst if str(x).strip()][:40]
89
+ return cls(**defaults)
90
+ except Exception as exc:
91
+ print(f"[DeviceDeck] Could not load config '{read_path}': {exc}")
92
+ return cls()
93
+
94
+ def save(self) -> None:
95
+ data = asdict(self)
96
+ data["session_bookmarks"] = [
97
+ _sanitize_bookmark(x)
98
+ for x in data.get("session_bookmarks", [])
99
+ if isinstance(x, dict)
100
+ ]
101
+ qc = data.get("ssh_quick_commands")
102
+ if isinstance(qc, list):
103
+ data["ssh_quick_commands"] = [
104
+ {"label": str(x.get("label", "")), "command": str(x.get("command", ""))}
105
+ for x in qc
106
+ if isinstance(x, dict) and (str(x.get("label", "")).strip() or str(x.get("command", "")).strip())
107
+ ]
108
+ text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
109
+ path = CONFIG_PATH
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ fd, tmp_path = tempfile.mkstemp(
112
+ suffix=".tmp",
113
+ prefix=f"{path.name}.",
114
+ dir=str(path.parent),
115
+ )
116
+ try:
117
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
118
+ f.write(text)
119
+ os.replace(tmp_path, path)
120
+ except Exception:
121
+ try:
122
+ os.unlink(tmp_path)
123
+ except OSError:
124
+ pass
125
+ raise
126
+ try:
127
+ if _LEGACY_CONFIG_PATH.is_file():
128
+ _LEGACY_CONFIG_PATH.unlink()
129
+ except OSError:
130
+ pass
@@ -0,0 +1 @@
1
+ # Service helpers package
@@ -0,0 +1,74 @@
1
+ """Parse `adb devices -l` for serial + human-readable device names (e.g. model name)."""
2
+
3
+ import re
4
+ from typing import List, Optional, Tuple
5
+
6
+ from .commands import run_adb
7
+
8
+
9
+ def _parse_devices_l_line(line: str) -> Optional[Tuple[str, str]]:
10
+ line = line.strip()
11
+ if not line or line.startswith("List of devices"):
12
+ return None
13
+ # Match: SERIAL <spaces> device|unauthorized|sideload|recovery (adb uses tabs or spaces)
14
+ m = re.match(r"^(\S+)\s+(device|unauthorized|sideload|recovery)\b", line)
15
+ if not m:
16
+ return None
17
+ serial, state = m.group(1), m.group(2)
18
+ if serial in ("List", "*", "adb"):
19
+ return None
20
+
21
+ m2 = re.search(r"\bmodel:([^\s]+)", line)
22
+ model = m2.group(1).replace("_", " ") if m2 else ""
23
+ if not model:
24
+ m3 = re.search(r"\bdevice:([^\s]+)", line)
25
+ model = m3.group(1).replace("_", " ") if m3 else ""
26
+ tag = f" [{state}]" if state != "device" else ""
27
+ if model:
28
+ display = f"{model} · {serial}{tag}"
29
+ else:
30
+ display = f"{serial}{tag}"
31
+ return serial, display
32
+
33
+
34
+ def list_adb_devices(adb_path: str) -> List[Tuple[str, str]]:
35
+ """
36
+ Return [(serial, display_label), ...].
37
+ Lists devices (handles space- or tab-separated lines).
38
+ """
39
+ code, stdout, _ = run_adb(adb_path, ["devices", "-l"])
40
+ out: List[Tuple[str, str]] = []
41
+ if code == 0 and stdout:
42
+ for line in stdout.splitlines():
43
+ parsed = _parse_devices_l_line(line)
44
+ if parsed:
45
+ out.append(parsed)
46
+ if out:
47
+ return out
48
+
49
+ code2, stdout2, _ = run_adb(adb_path, ["devices"])
50
+ if code2 != 0:
51
+ return []
52
+ for line in stdout2.splitlines():
53
+ line = line.strip()
54
+ if not line or line.startswith("List of devices"):
55
+ continue
56
+ m = re.match(r"^(\S+)\s+(device|unauthorized|sideload|recovery)\s*$", line)
57
+ if m:
58
+ serial = m.group(1)
59
+ state = m.group(2)
60
+ tag = f" [{state}]" if state != "device" else ""
61
+ out.append((serial, f"{serial}{tag}"))
62
+ return out
63
+
64
+
65
+ def friendly_name_for_serial(adb_path: str, serial: str) -> str:
66
+ """Short name for tabs (e.g. 'Palq'); falls back to serial."""
67
+ if not serial:
68
+ return "ADB"
69
+ for s, display in list_adb_devices(adb_path):
70
+ if s == serial:
71
+ if " · " in display:
72
+ return display.split(" · ", 1)[0].strip()
73
+ return display
74
+ return serial
@@ -0,0 +1,96 @@
1
+ import re
2
+ import subprocess
3
+ import threading
4
+ import sys
5
+ from typing import Callable, List, Optional, Tuple
6
+
7
+
8
+ def _win_subprocess_flags() -> dict:
9
+ """Hide spawned console windows for background commands on Windows."""
10
+ if sys.platform != "win32":
11
+ return {}
12
+ startupinfo = subprocess.STARTUPINFO()
13
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
14
+ startupinfo.wShowWindow = 0 # SW_HIDE
15
+ return {
16
+ "creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
17
+ "startupinfo": startupinfo,
18
+ }
19
+
20
+
21
+ def run_command(command: List[str], timeout: int = 20) -> Tuple[int, str, str]:
22
+ try:
23
+ result = subprocess.run(
24
+ command,
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=timeout,
28
+ shell=False,
29
+ **_win_subprocess_flags(),
30
+ )
31
+ return result.returncode, result.stdout, result.stderr
32
+ except FileNotFoundError:
33
+ return 127, "", f"Command not found: {command[0]}"
34
+ except subprocess.TimeoutExpired:
35
+ return 124, "", "Command timed out"
36
+
37
+
38
+ def run_adb(adb_path: str, args: List[str], timeout: int = 20) -> Tuple[int, str, str]:
39
+ return run_command([adb_path, *args], timeout=timeout)
40
+
41
+
42
+ _pct_re = re.compile(r"(\d{1,3})\s*%")
43
+
44
+
45
+ def run_adb_with_line_callback(
46
+ adb_path: str,
47
+ args: List[str],
48
+ timeout: int = 600,
49
+ on_line: Optional[Callable[[str], None]] = None,
50
+ on_percent: Optional[Callable[[int], None]] = None,
51
+ ) -> Tuple[int, str, str]:
52
+ """Run adb merging stderr into stdout; invoke on_line per line and on_percent when N%% is seen."""
53
+ cmd = [adb_path, *args]
54
+ try:
55
+ proc = subprocess.Popen(
56
+ cmd,
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.STDOUT,
59
+ stdin=subprocess.DEVNULL,
60
+ text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
63
+ bufsize=1,
64
+ **_win_subprocess_flags(),
65
+ )
66
+ except FileNotFoundError:
67
+ return 127, "", f"Command not found: {adb_path}"
68
+
69
+ out_chunks: List[str] = []
70
+
71
+ def reader():
72
+ assert proc.stdout is not None
73
+ for line in iter(proc.stdout.readline, ""):
74
+ out_chunks.append(line)
75
+ if on_line:
76
+ on_line(line.rstrip("\n"))
77
+ if on_percent:
78
+ for m in _pct_re.finditer(line):
79
+ try:
80
+ p = int(m.group(1))
81
+ if 0 <= p <= 100:
82
+ on_percent(p)
83
+ except ValueError:
84
+ pass
85
+ proc.stdout.close()
86
+
87
+ t = threading.Thread(target=reader, daemon=True)
88
+ t.start()
89
+ try:
90
+ proc.wait(timeout=timeout)
91
+ except subprocess.TimeoutExpired:
92
+ proc.kill()
93
+ proc.wait()
94
+ return 124, "".join(out_chunks), "Command timed out"
95
+ t.join(timeout=2.0)
96
+ return proc.returncode or 0, "".join(out_chunks), ""
@@ -0,0 +1,84 @@
1
+ """SFTP / FTP connection helpers for the unified session model."""
2
+
3
+ import socket
4
+ from ftplib import FTP, error_perm
5
+ from typing import Optional, Tuple
6
+
7
+ try:
8
+ import paramiko
9
+ except ImportError:
10
+ paramiko = None # type: ignore
11
+
12
+
13
+ def connect_sftp(
14
+ host: str,
15
+ port: int,
16
+ username: str,
17
+ password: str,
18
+ timeout: int = 25,
19
+ ) -> Tuple[Optional["paramiko.Transport"], Optional["paramiko.SFTPClient"], str]:
20
+ if paramiko is None:
21
+ return None, None, "paramiko is not installed (pip install paramiko)"
22
+ if not host.strip():
23
+ return None, None, "Host is empty"
24
+ sock = None
25
+ try:
26
+ sock = socket.create_connection((host.strip(), int(port)), timeout=timeout)
27
+ t = paramiko.Transport(sock)
28
+ t.banner_timeout = timeout
29
+ t.auth_timeout = timeout
30
+ uname = (username or "").strip() or None
31
+ pwd = password if password else None
32
+ t.connect(username=uname, password=pwd)
33
+ sftp = paramiko.SFTPClient.from_transport(t)
34
+ return t, sftp, ""
35
+ except Exception as exc:
36
+ try:
37
+ if sock is not None:
38
+ sock.close()
39
+ except Exception:
40
+ pass
41
+ return None, None, str(exc)
42
+
43
+
44
+ def disconnect_sftp(transport, sftp) -> None:
45
+ try:
46
+ if sftp is not None:
47
+ sftp.close()
48
+ except Exception:
49
+ pass
50
+ try:
51
+ if transport is not None:
52
+ transport.close()
53
+ except Exception:
54
+ pass
55
+
56
+
57
+ def connect_ftp(
58
+ host: str,
59
+ port: int,
60
+ user: str,
61
+ password: str,
62
+ timeout: int = 25,
63
+ ) -> Tuple[Optional[FTP], str]:
64
+ if not host.strip():
65
+ return None, "Host is empty"
66
+ try:
67
+ ftp = FTP()
68
+ ftp.connect(host.strip(), int(port), timeout=timeout)
69
+ ftp.login(user or "", password or "")
70
+ return ftp, ""
71
+ except Exception as exc:
72
+ return None, str(exc)
73
+
74
+
75
+ def disconnect_ftp(ftp: Optional[FTP]) -> None:
76
+ if ftp is None:
77
+ return
78
+ try:
79
+ ftp.quit()
80
+ except Exception:
81
+ try:
82
+ ftp.close()
83
+ except Exception:
84
+ pass