unstrap 0.3.2__py3-none-win_amd64.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.
- unstrap/__init__.py +3 -0
- unstrap/__main__.py +7 -0
- unstrap/_vendor/mtp-rs.exe +0 -0
- unstrap/api.py +224 -0
- unstrap/autostart.py +375 -0
- unstrap/cli.py +567 -0
- unstrap/console.py +9 -0
- unstrap/diagnostics.py +123 -0
- unstrap/install.py +60 -0
- unstrap/notify.py +119 -0
- unstrap/policy.py +84 -0
- unstrap/py.typed +0 -0
- unstrap/sync.py +431 -0
- unstrap/trace.py +28 -0
- unstrap/transport/__init__.py +3 -0
- unstrap/transport/base.py +71 -0
- unstrap/transport/directory.py +74 -0
- unstrap/transport/mtp_rs.py +350 -0
- unstrap-0.3.2.dist-info/METADATA +57 -0
- unstrap-0.3.2.dist-info/RECORD +25 -0
- unstrap-0.3.2.dist-info/WHEEL +4 -0
- unstrap-0.3.2.dist-info/entry_points.txt +2 -0
- unstrap-0.3.2.dist-info/licenses/LICENSE +21 -0
- unstrap-0.3.2.dist-info/licenses/mtp-rs/LICENSE-APACHE +201 -0
- unstrap-0.3.2.dist-info/licenses/mtp-rs/LICENSE-MIT +21 -0
unstrap/__init__.py
ADDED
unstrap/__main__.py
ADDED
|
Binary file
|
unstrap/api.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HTTP client for the Unstrap sync API (plan §4). Retries only what is safe to retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from unstrap import __version__, trace
|
|
16
|
+
from unstrap.transport import DeviceInfo, RemoteFile
|
|
17
|
+
|
|
18
|
+
DEFAULT_SERVER = "https://unstrap.sweatstack.no"
|
|
19
|
+
MAX_ATTEMPTS = 5
|
|
20
|
+
REPORT_TIMEOUT_SECONDS = 5.0 # a failure report is best effort: never make a failure slower
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(Exception):
|
|
24
|
+
"""The server said no. `message` is meant for people and is printed as-is."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientTooOld(ApiError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerUnreachable(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class HashedFile:
|
|
43
|
+
path: str
|
|
44
|
+
size: int
|
|
45
|
+
sha256: str
|
|
46
|
+
|
|
47
|
+
def as_json(self) -> dict[str, Any]:
|
|
48
|
+
return {"path": self.path, "size": self.size, "sha256": self.sha256}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def user_agent(mtp_rs_version: str | None) -> str:
|
|
52
|
+
system = f"{platform.system()} {platform.machine()}".strip()
|
|
53
|
+
return f"unstrap/{__version__} ({system}; mtp-rs/{mtp_rs_version or 'none'})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Api:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
base_url: str = DEFAULT_SERVER,
|
|
60
|
+
*,
|
|
61
|
+
agent: str | None = None,
|
|
62
|
+
client: httpx.Client | None = None,
|
|
63
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.base_url = base_url.rstrip("/")
|
|
66
|
+
self._client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0))
|
|
67
|
+
self._agent = agent or user_agent(None)
|
|
68
|
+
self._sleep = sleep
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._client.close()
|
|
72
|
+
|
|
73
|
+
# -- plumbing ---------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
token: str | None = None,
|
|
81
|
+
json: Any = None,
|
|
82
|
+
content: bytes | None = None,
|
|
83
|
+
headers: dict[str, str] | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
h = {"user-agent": self._agent, **(headers or {})}
|
|
86
|
+
if token:
|
|
87
|
+
h["authorization"] = f"Bearer {token}"
|
|
88
|
+
url = f"{self.base_url}/api/v1{path}"
|
|
89
|
+
last: Exception | None = None
|
|
90
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
91
|
+
try:
|
|
92
|
+
with trace.timed(f"{method} {path}"):
|
|
93
|
+
res = self._client.request(method, url, json=json, content=content, headers=h)
|
|
94
|
+
except httpx.TransportError as e:
|
|
95
|
+
last = e
|
|
96
|
+
self._backoff(attempt, None)
|
|
97
|
+
continue
|
|
98
|
+
if res.status_code == 429 or res.status_code >= 500:
|
|
99
|
+
last = ApiError(res.status_code, *_error(res))
|
|
100
|
+
self._backoff(attempt, res.headers.get("retry-after"))
|
|
101
|
+
continue
|
|
102
|
+
if res.status_code >= 400:
|
|
103
|
+
code, message = _error(res)
|
|
104
|
+
if res.status_code == 426:
|
|
105
|
+
raise ClientTooOld(426, code, message)
|
|
106
|
+
raise ApiError(res.status_code, code, message)
|
|
107
|
+
try:
|
|
108
|
+
data: dict[str, Any] = res.json()
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
# E.g. a captive portal or proxy answering in HTML (review M3).
|
|
111
|
+
raise ServerUnreachable(
|
|
112
|
+
f"Unexpected answer from {self.base_url} (a Wi-Fi login page or proxy?)"
|
|
113
|
+
) from e
|
|
114
|
+
return data
|
|
115
|
+
if isinstance(last, ApiError):
|
|
116
|
+
raise last
|
|
117
|
+
raise ServerUnreachable(f"Can't reach {self.base_url}: {last}")
|
|
118
|
+
|
|
119
|
+
def _backoff(self, attempt: int, retry_after: str | None) -> None:
|
|
120
|
+
if attempt + 1 >= MAX_ATTEMPTS:
|
|
121
|
+
return
|
|
122
|
+
if retry_after and retry_after.isdigit():
|
|
123
|
+
delay = min(float(retry_after), 60.0)
|
|
124
|
+
else:
|
|
125
|
+
delay = min(2.0**attempt, 30.0) * (0.5 + random.random() / 2)
|
|
126
|
+
self._sleep(delay)
|
|
127
|
+
|
|
128
|
+
# -- endpoints --------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def healthz(self) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
res = self._client.get(f"{self.base_url}/healthz", headers={"user-agent": self._agent})
|
|
133
|
+
except httpx.TransportError:
|
|
134
|
+
return False
|
|
135
|
+
return res.status_code == 200
|
|
136
|
+
|
|
137
|
+
def start(self, device: DeviceInfo, listing: Sequence[RemoteFile]) -> dict[str, Any]:
|
|
138
|
+
dev: dict[str, Any] = {"usb_serial": device.usb_serial, "unit_id": device.unit_id}
|
|
139
|
+
if device.product:
|
|
140
|
+
dev["product"] = device.product[:100]
|
|
141
|
+
if device.firmware:
|
|
142
|
+
dev["firmware"] = device.firmware[:32]
|
|
143
|
+
body = {"device": dev, "listing": [{"path": f.path, "size": f.size} for f in listing]}
|
|
144
|
+
return self._request("POST", "/syncs", json=body)
|
|
145
|
+
|
|
146
|
+
def prove(
|
|
147
|
+
self, sync_id: int, token: str, nonce: str, proofs: list[dict[str, str]]
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
body = {"nonce": nonce, "proofs": proofs}
|
|
150
|
+
return self._request("POST", f"/syncs/{sync_id}/prove", token=token, json=body)
|
|
151
|
+
|
|
152
|
+
def check(self, sync_id: int, token: str, files: Sequence[HashedFile]) -> list[str]:
|
|
153
|
+
body = {"files": [f.as_json() for f in files]}
|
|
154
|
+
res = self._request("POST", f"/syncs/{sync_id}/check", token=token, json=body)
|
|
155
|
+
return list(res["missing"])
|
|
156
|
+
|
|
157
|
+
def put_blob(self, sync_id: int, token: str, file: HashedFile, data: bytes) -> bool:
|
|
158
|
+
headers = {
|
|
159
|
+
"unstrap-path": quote(file.path, safe=""),
|
|
160
|
+
"content-type": "application/octet-stream",
|
|
161
|
+
}
|
|
162
|
+
res = self._request(
|
|
163
|
+
"PUT",
|
|
164
|
+
f"/syncs/{sync_id}/blobs/{file.sha256}",
|
|
165
|
+
token=token,
|
|
166
|
+
content=data,
|
|
167
|
+
headers=headers,
|
|
168
|
+
)
|
|
169
|
+
return bool(res.get("stored"))
|
|
170
|
+
|
|
171
|
+
def finish(
|
|
172
|
+
self,
|
|
173
|
+
sync_id: int,
|
|
174
|
+
token: str,
|
|
175
|
+
files: Sequence[HashedFile],
|
|
176
|
+
stats: dict[str, int | str | bool],
|
|
177
|
+
errors: list[str],
|
|
178
|
+
*,
|
|
179
|
+
timings: dict[str, int] | None = None,
|
|
180
|
+
env: dict[str, str] | None = None,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
body: dict[str, Any] = {
|
|
183
|
+
"files": [f.as_json() for f in files],
|
|
184
|
+
"stats": stats,
|
|
185
|
+
"errors": errors[:100],
|
|
186
|
+
}
|
|
187
|
+
if timings:
|
|
188
|
+
body["timings"] = timings
|
|
189
|
+
if env:
|
|
190
|
+
body["env"] = env
|
|
191
|
+
return self._request("POST", f"/syncs/{sync_id}/finish", token=token, json=body)
|
|
192
|
+
|
|
193
|
+
# -- diagnostics (plan 004): one attempt, short timeout, never raises ------------------------
|
|
194
|
+
|
|
195
|
+
def report_failure(self, sync_id: int, token: str, body: dict[str, Any]) -> bool:
|
|
196
|
+
"""Tell the server why this sync gave up; it ends the sync with that reason."""
|
|
197
|
+
return self._best_effort(f"/syncs/{sync_id}/report", body, token=token)
|
|
198
|
+
|
|
199
|
+
def report_error(self, body: dict[str, Any]) -> bool:
|
|
200
|
+
"""An anonymous report of a failure before any sync existed."""
|
|
201
|
+
return self._best_effort("/reports", body)
|
|
202
|
+
|
|
203
|
+
def _best_effort(self, path: str, body: dict[str, Any], token: str | None = None) -> bool:
|
|
204
|
+
headers = {"user-agent": self._agent}
|
|
205
|
+
if token:
|
|
206
|
+
headers["authorization"] = f"Bearer {token}"
|
|
207
|
+
try:
|
|
208
|
+
res = self._client.post(
|
|
209
|
+
f"{self.base_url}/api/v1{path}",
|
|
210
|
+
json=body,
|
|
211
|
+
headers=headers,
|
|
212
|
+
timeout=REPORT_TIMEOUT_SECONDS,
|
|
213
|
+
)
|
|
214
|
+
except Exception: # best effort: a report never becomes a second failure
|
|
215
|
+
return False
|
|
216
|
+
return res.status_code < 300
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _error(res: httpx.Response) -> tuple[str, str]:
|
|
220
|
+
try:
|
|
221
|
+
err = res.json()["error"]
|
|
222
|
+
return str(err["code"]), str(err["message"])
|
|
223
|
+
except (ValueError, KeyError, TypeError):
|
|
224
|
+
return "http_error", f"Server returned HTTP {res.status_code}."
|
unstrap/autostart.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""`unstrap autostart`: run `unstrap listen` in the background at login.
|
|
2
|
+
|
|
3
|
+
macOS: a LaunchAgent (launchd), restarted if it crashes. Linux: a systemd user service, likewise.
|
|
4
|
+
Windows (plan 003): the current user's Run key (no admin; listed in Task Manager > Startup apps),
|
|
5
|
+
running pythonw.exe so no console window opens, and started right away. Everything that touches
|
|
6
|
+
the system (commands, home directory, uid, PATH lookups, registry, processes) is injected, so
|
|
7
|
+
tests touch none of it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import plistlib
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path, PureWindowsPath
|
|
23
|
+
from typing import Protocol
|
|
24
|
+
|
|
25
|
+
from unstrap import __version__
|
|
26
|
+
from unstrap.api import DEFAULT_SERVER
|
|
27
|
+
from unstrap.console import NO_WINDOW
|
|
28
|
+
from unstrap.install import WINDOWS_INSTALL_COMMAND, from_uvx # uvx envs can be cleaned away
|
|
29
|
+
|
|
30
|
+
LABEL = "no.sweatstack.unstrap"
|
|
31
|
+
SERVICE = "unstrap.service"
|
|
32
|
+
BOOTOUT_WAIT_SECONDS = 10.0
|
|
33
|
+
LINGER_HINT = (
|
|
34
|
+
"This needs a systemd user session. On a headless machine or over SSH, run "
|
|
35
|
+
"`loginctl enable-linger $USER` first."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
39
|
+
RUN_VALUE = "Unstrap"
|
|
40
|
+
|
|
41
|
+
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AutostartError(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
49
|
+
return subprocess.run(
|
|
50
|
+
cmd, capture_output=True, text=True, timeout=60, check=False, creationflags=NO_WINDOW
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Registry(Protocol):
|
|
55
|
+
"""The one registry value autostart owns on Windows: HKCU\\...\\Run\\Unstrap."""
|
|
56
|
+
|
|
57
|
+
def get(self) -> str | None: ...
|
|
58
|
+
def set(self, command: str) -> None: ...
|
|
59
|
+
def delete(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunKey:
|
|
63
|
+
"""The real HKCU Run key (Windows only)."""
|
|
64
|
+
|
|
65
|
+
def get(self) -> str | None:
|
|
66
|
+
if sys.platform != "win32":
|
|
67
|
+
return None
|
|
68
|
+
import winreg # noqa: PLC0415 (Windows-only module)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
|
|
72
|
+
value, _kind = winreg.QueryValueEx(key, RUN_VALUE)
|
|
73
|
+
return str(value)
|
|
74
|
+
except OSError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def set(self, command: str) -> None:
|
|
78
|
+
if sys.platform != "win32":
|
|
79
|
+
raise AutostartError("The Run key exists on Windows only.")
|
|
80
|
+
import winreg # noqa: PLC0415
|
|
81
|
+
|
|
82
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
|
|
83
|
+
winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, command)
|
|
84
|
+
|
|
85
|
+
def delete(self) -> None:
|
|
86
|
+
if sys.platform != "win32":
|
|
87
|
+
return
|
|
88
|
+
import winreg # noqa: PLC0415
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
|
|
92
|
+
winreg.DeleteValue(key, RUN_VALUE)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _spawn(cmd: list[str]) -> None:
|
|
98
|
+
"""Start a process that outlives us, with no console window (Windows)."""
|
|
99
|
+
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
|
100
|
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
|
101
|
+
)
|
|
102
|
+
subprocess.Popen(
|
|
103
|
+
cmd,
|
|
104
|
+
stdin=subprocess.DEVNULL,
|
|
105
|
+
stdout=subprocess.DEVNULL,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
close_fds=True,
|
|
108
|
+
creationflags=flags | NO_WINDOW,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class System:
|
|
114
|
+
"""The bits of the OS autostart uses; the defaults are the real ones."""
|
|
115
|
+
|
|
116
|
+
name: str # platform.system(): "Darwin", "Linux", ...
|
|
117
|
+
home: Path = field(default_factory=Path.home)
|
|
118
|
+
uid: int = field(default_factory=lambda: os.getuid() if hasattr(os, "getuid") else -1)
|
|
119
|
+
which: Callable[[str], str | None] = shutil.which
|
|
120
|
+
run: Runner = _run
|
|
121
|
+
executable: str = sys.executable
|
|
122
|
+
prefix: str = sys.prefix # this Python environment
|
|
123
|
+
sleep: Callable[[float], None] = time.sleep
|
|
124
|
+
env: Mapping[str, str] = field(default_factory=lambda: dict(os.environ))
|
|
125
|
+
registry: Registry = field(default_factory=RunKey)
|
|
126
|
+
spawn: Callable[[list[str]], None] = _spawn
|
|
127
|
+
pid: int = field(default_factory=os.getpid)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_pinned(cmd: list[str]) -> bool:
|
|
131
|
+
"""Whether `cmd` (from listen_command) runs a PyPI version through uvx."""
|
|
132
|
+
return len(cmd) > 1 and cmd[1].startswith("unstrap==")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def listen_command(
|
|
136
|
+
system: System, *, server: str, device: str | None, notify: bool = True
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
"""What the OS runs: this very environment's Python, unless that is a throwaway uvx one.
|
|
139
|
+
Then uvx, pinned to this version so it keeps working from uv's cache, offline; after an
|
|
140
|
+
upgrade, `uvx unstrap@latest autostart` registers the new version. (Only from uvx: a dev
|
|
141
|
+
or unreleased build pinned like that would fetch a different release from PyPI.)"""
|
|
142
|
+
if system.name == "Windows":
|
|
143
|
+
if from_uvx(system.prefix):
|
|
144
|
+
# uvx's environment can be cleaned away, and uvx.exe opens a console window.
|
|
145
|
+
raise AutostartError(
|
|
146
|
+
f"On Windows, install Unstrap first (Windows key + R, then paste):\n "
|
|
147
|
+
f"{WINDOWS_INSTALL_COMMAND}"
|
|
148
|
+
)
|
|
149
|
+
# pythonw.exe: the same environment's Python without a console window.
|
|
150
|
+
pythonw = str(PureWindowsPath(system.executable).with_name("pythonw.exe"))
|
|
151
|
+
cmd = [pythonw, "-m", "unstrap", "listen"]
|
|
152
|
+
return _with_options(cmd, server=server, device=device, notify=notify)
|
|
153
|
+
uvx = system.which("uvx") if from_uvx(system.prefix) else None
|
|
154
|
+
if uvx:
|
|
155
|
+
cmd = [os.path.abspath(uvx), f"unstrap=={__version__}", "listen"]
|
|
156
|
+
else:
|
|
157
|
+
cmd = [system.executable, "-m", "unstrap", "listen"]
|
|
158
|
+
return _with_options(cmd, server=server, device=device, notify=notify)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _with_options(cmd: list[str], *, server: str, device: str | None, notify: bool) -> list[str]:
|
|
162
|
+
if server.rstrip("/") != DEFAULT_SERVER:
|
|
163
|
+
cmd += ["--server", server]
|
|
164
|
+
if device:
|
|
165
|
+
cmd += ["--device", device]
|
|
166
|
+
if not notify:
|
|
167
|
+
cmd.append("--no-notify")
|
|
168
|
+
return cmd
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def plist_path(system: System) -> Path:
|
|
172
|
+
return system.home / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def unit_path(system: System) -> Path:
|
|
176
|
+
return system.home / ".config" / "systemd" / "user" / SERVICE
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def log_path(system: System) -> Path:
|
|
180
|
+
if system.name == "Windows":
|
|
181
|
+
return data_dir(system) / "unstrap.log"
|
|
182
|
+
return system.home / "Library" / "Logs" / "unstrap.log"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def data_dir(system: System) -> Path:
|
|
186
|
+
"""Windows: %LOCALAPPDATA%\\Unstrap (the background listener's log and pid file)."""
|
|
187
|
+
local = system.env.get("LOCALAPPDATA") or str(system.home / "AppData" / "Local")
|
|
188
|
+
return Path(local) / "Unstrap"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def pid_path(system: System) -> Path:
|
|
192
|
+
return data_dir(system) / "listen.pid"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@contextmanager
|
|
196
|
+
def recorded(system: System) -> Iterator[None]:
|
|
197
|
+
"""While `listen` runs on Windows, its pid is on file, so autostart can stop it (before an
|
|
198
|
+
update, or with --off). Elsewhere launchd and systemd do that job."""
|
|
199
|
+
if system.name != "Windows":
|
|
200
|
+
yield
|
|
201
|
+
return
|
|
202
|
+
path = pid_path(system)
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
path.write_text(str(system.pid))
|
|
205
|
+
try:
|
|
206
|
+
yield
|
|
207
|
+
finally:
|
|
208
|
+
try:
|
|
209
|
+
if path.read_text().strip() == str(system.pid):
|
|
210
|
+
path.unlink()
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def stop_listener(system: System) -> bool:
|
|
216
|
+
"""Stop the background listener recorded in the pid file, if it is still running.
|
|
217
|
+
|
|
218
|
+
Windows locks a running program's files, so the installer must stop it before updating.
|
|
219
|
+
Only a Python process is stopped: after a reboot the recorded pid may belong to anything.
|
|
220
|
+
"""
|
|
221
|
+
path = pid_path(system)
|
|
222
|
+
try:
|
|
223
|
+
pid = int(path.read_text().strip())
|
|
224
|
+
except (OSError, ValueError):
|
|
225
|
+
return False
|
|
226
|
+
stopped = False
|
|
227
|
+
try:
|
|
228
|
+
found = system.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"])
|
|
229
|
+
if "python" in found.stdout.lower():
|
|
230
|
+
system.run(["taskkill", "/PID", str(pid), "/F"])
|
|
231
|
+
system.sleep(1.0) # let Windows release the files
|
|
232
|
+
stopped = True
|
|
233
|
+
except (OSError, subprocess.SubprocessError):
|
|
234
|
+
pass
|
|
235
|
+
path.unlink(missing_ok=True)
|
|
236
|
+
return stopped
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def launch_agent(cmd: list[str], log: Path) -> bytes:
|
|
240
|
+
return plistlib.dumps(
|
|
241
|
+
{
|
|
242
|
+
"Label": LABEL,
|
|
243
|
+
"ProgramArguments": cmd,
|
|
244
|
+
"RunAtLoad": True,
|
|
245
|
+
"KeepAlive": True,
|
|
246
|
+
"ProcessType": "Background",
|
|
247
|
+
"StandardOutPath": str(log),
|
|
248
|
+
"StandardErrorPath": str(log),
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def systemd_unit(cmd: list[str]) -> str:
|
|
254
|
+
return (
|
|
255
|
+
"[Unit]\n"
|
|
256
|
+
"Description=Unstrap: sync Garmin devices when they are connected\n"
|
|
257
|
+
"\n"
|
|
258
|
+
"[Service]\n"
|
|
259
|
+
f"ExecStart={' '.join(_systemd_quote(a) for a in cmd)}\n"
|
|
260
|
+
"Restart=on-failure\n"
|
|
261
|
+
"RestartSec=10\n"
|
|
262
|
+
"\n"
|
|
263
|
+
"[Install]\n"
|
|
264
|
+
"WantedBy=default.target\n"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _systemd_quote(arg: str) -> str:
|
|
269
|
+
if not re.fullmatch(r"[A-Za-z0-9_./=:@+,-]+", arg):
|
|
270
|
+
arg = '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
271
|
+
# systemd expands %specifiers and $VARIABLES in ExecStart; keep them literal.
|
|
272
|
+
return arg.replace("%", "%%").replace("$", "$$")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def installed(system: System) -> str | None:
|
|
276
|
+
"""Where autostart is registered (the LaunchAgent, unit file or Run key), if it is."""
|
|
277
|
+
if system.name == "Windows":
|
|
278
|
+
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}" if system.registry.get() else None
|
|
279
|
+
path = {"Darwin": plist_path, "Linux": unit_path}.get(system.name)
|
|
280
|
+
if path is None:
|
|
281
|
+
return None
|
|
282
|
+
p = path(system)
|
|
283
|
+
return str(p) if p.is_file() else None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def enable(system: System, cmd: list[str]) -> list[str]:
|
|
287
|
+
"""Install and start; returns lines to show the user."""
|
|
288
|
+
if system.name == "Darwin":
|
|
289
|
+
plist, log = plist_path(system), log_path(system)
|
|
290
|
+
plist.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
plist.write_bytes(launch_agent(cmd, log))
|
|
293
|
+
_call(system, ["launchctl", "bootout", f"gui/{system.uid}/{LABEL}"], check=False)
|
|
294
|
+
_wait_until_unloaded(system) # bootout is asynchronous; bootstrap fails while it runs
|
|
295
|
+
_call(system, ["launchctl", "bootstrap", f"gui/{system.uid}", str(plist)])
|
|
296
|
+
return [f"Installed {plist}", f"Logs: {log}"]
|
|
297
|
+
if system.name == "Linux":
|
|
298
|
+
unit = unit_path(system)
|
|
299
|
+
unit.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
unit.write_text(systemd_unit(cmd))
|
|
301
|
+
try:
|
|
302
|
+
_call(system, ["systemctl", "--user", "daemon-reload"])
|
|
303
|
+
_call(system, ["systemctl", "--user", "enable", SERVICE])
|
|
304
|
+
# restart, not start: re-running autostart after an upgrade replaces the old process.
|
|
305
|
+
_call(system, ["systemctl", "--user", "restart", SERVICE])
|
|
306
|
+
except AutostartError as e:
|
|
307
|
+
unit.unlink(missing_ok=True) # half-installed must not look "on" (doctor)
|
|
308
|
+
raise AutostartError(f"{e}\n{LINGER_HINT}") from e
|
|
309
|
+
return [f"Installed {unit}", "Logs: journalctl --user -u unstrap"]
|
|
310
|
+
if system.name == "Windows":
|
|
311
|
+
stop_listener(system) # re-running autostart (e.g. after an update) replaces it
|
|
312
|
+
system.registry.set(subprocess.list2cmdline(cmd))
|
|
313
|
+
system.spawn(cmd) # now, not only from the next login
|
|
314
|
+
return [
|
|
315
|
+
"Starts at login: Task Manager > Startup apps > Unstrap",
|
|
316
|
+
f"Logs: {log_path(system)}",
|
|
317
|
+
]
|
|
318
|
+
raise AutostartError(f"Autostart isn't supported on {system.name or 'this system'}.")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def disable(system: System) -> list[str]:
|
|
322
|
+
"""Stop and remove; fine if it was never installed."""
|
|
323
|
+
if system.name == "Darwin":
|
|
324
|
+
plist = plist_path(system)
|
|
325
|
+
_call(system, ["launchctl", "bootout", f"gui/{system.uid}/{LABEL}"], check=False)
|
|
326
|
+
return [_remove(plist)]
|
|
327
|
+
if system.name == "Linux":
|
|
328
|
+
unit = unit_path(system)
|
|
329
|
+
_call(system, ["systemctl", "--user", "disable", "--now", SERVICE], check=False)
|
|
330
|
+
line = _remove(unit)
|
|
331
|
+
_call(system, ["systemctl", "--user", "daemon-reload"])
|
|
332
|
+
return [line]
|
|
333
|
+
if system.name == "Windows":
|
|
334
|
+
stopped = stop_listener(system)
|
|
335
|
+
was = system.registry.get() is not None
|
|
336
|
+
system.registry.delete()
|
|
337
|
+
if was:
|
|
338
|
+
return ["Removed Unstrap from the startup apps."]
|
|
339
|
+
return ["Stopped the running listener." if stopped else "Autostart was not installed."]
|
|
340
|
+
raise AutostartError(f"Autostart isn't supported on {system.name or 'this system'}.")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _wait_until_unloaded(system: System) -> None:
|
|
344
|
+
waited = 0.0
|
|
345
|
+
while waited < BOOTOUT_WAIT_SECONDS and _succeeds(
|
|
346
|
+
system, ["launchctl", "print", f"gui/{system.uid}/{LABEL}"]
|
|
347
|
+
):
|
|
348
|
+
system.sleep(0.5)
|
|
349
|
+
waited += 0.5
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _succeeds(system: System, cmd: list[str]) -> bool:
|
|
353
|
+
try:
|
|
354
|
+
return system.run(cmd).returncode == 0
|
|
355
|
+
except (OSError, subprocess.SubprocessError):
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _remove(path: Path) -> str:
|
|
360
|
+
if not path.exists():
|
|
361
|
+
return "Autostart was not installed."
|
|
362
|
+
path.unlink()
|
|
363
|
+
return f"Removed {path}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _call(system: System, cmd: list[str], *, check: bool = True) -> None:
|
|
367
|
+
try:
|
|
368
|
+
res = system.run(cmd)
|
|
369
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
370
|
+
if check:
|
|
371
|
+
raise AutostartError(f"Can't run {cmd[0]}: {e}") from e
|
|
372
|
+
return
|
|
373
|
+
if check and res.returncode != 0:
|
|
374
|
+
detail = (res.stderr or res.stdout or "").strip() or f"exit code {res.returncode}"
|
|
375
|
+
raise AutostartError(f"{' '.join(cmd)} failed: {detail}")
|