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 ADDED
@@ -0,0 +1,3 @@
1
+ """Unstrap: copy Garmin device data off the device over USB (MTP) and upload it."""
2
+
3
+ __version__ = "0.3.2"
unstrap/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m unstrap`: what autostart registers when uvx isn't available."""
2
+
3
+ import sys
4
+
5
+ from unstrap.cli import main
6
+
7
+ sys.exit(main())
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}")