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.
- adbsshdeck-0.1.1.dist-info/LICENSE +21 -0
- adbsshdeck-0.1.1.dist-info/METADATA +136 -0
- adbsshdeck-0.1.1.dist-info/RECORD +29 -0
- adbsshdeck-0.1.1.dist-info/WHEEL +5 -0
- adbsshdeck-0.1.1.dist-info/entry_points.txt +2 -0
- adbsshdeck-0.1.1.dist-info/top_level.txt +1 -0
- devicedeck/__init__.py +4 -0
- devicedeck/__main__.py +4 -0
- devicedeck/app.py +45 -0
- devicedeck/config.py +130 -0
- devicedeck/services/__init__.py +1 -0
- devicedeck/services/adb_devices.py +74 -0
- devicedeck/services/commands.py +96 -0
- devicedeck/services/remote_clients.py +84 -0
- devicedeck/session.py +52 -0
- devicedeck/ui/__init__.py +1 -0
- devicedeck/ui/app_icon.py +46 -0
- devicedeck/ui/combo_utils.py +45 -0
- devicedeck/ui/first_run_dialog.py +107 -0
- devicedeck/ui/icon_utils.py +144 -0
- devicedeck/ui/main_window.py +691 -0
- devicedeck/ui/preferences_dialog.py +122 -0
- devicedeck/ui/session_login_dialog.py +795 -0
- devicedeck/ui/styles.py +1021 -0
- devicedeck/ui/tabs/__init__.py +1 -0
- devicedeck/ui/tabs/file_explorer_tab.py +3680 -0
- devicedeck/ui/tabs/scrcpy_tab.py +770 -0
- devicedeck/ui/tabs/terminal_tab.py +1192 -0
- devicedeck/ui/win_scrcpy_hotkey.py +76 -0
|
@@ -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 @@
|
|
|
1
|
+
devicedeck
|
devicedeck/__init__.py
ADDED
devicedeck/__main__.py
ADDED
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
|