lange-python 0.3.12__tar.gz → 0.3.13__tar.gz
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.
- {lange_python-0.3.12 → lange_python-0.3.13}/PKG-INFO +13 -8
- {lange_python-0.3.12 → lange_python-0.3.13}/README.md +12 -7
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/distribution/_client.py +132 -17
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/distribution/_update_macos.py +41 -11
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/distribution/_util.py +58 -8
- {lange_python-0.3.12 → lange_python-0.3.13}/pyproject.toml +1 -1
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/__main__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/_util/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/_util/_base_client.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/_util/_key_handling.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/_command.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/_discovery.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/_docker.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/_poetry.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/build/_types.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/code/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/code/_stats.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/distribution/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/cli/distribution/_command.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/distribution/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/tunnel/__init__.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/tunnel/_client.py +0 -0
- {lange_python-0.3.12 → lange_python-0.3.13}/lange/tunnel/_util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lange-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.13
|
|
4
4
|
Summary: A bundeld set of tools, clients for the lange-suite of tools and more.
|
|
5
5
|
Author: contact@robertlange.me
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -42,13 +42,17 @@ from lange.distribution import DistributionClient
|
|
|
42
42
|
|
|
43
43
|
client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
latest = client.check_for_update("1.2.3")
|
|
46
|
+
if latest:
|
|
47
|
+
version, artifact_path = client.download(current_version="1.2.3")
|
|
48
|
+
print(version, artifact_path)
|
|
49
|
+
update_metadata = client.update(
|
|
50
|
+
current_version="1.2.3",
|
|
51
|
+
installed_app_path=Path("/Applications/Desktop App.app"),
|
|
52
|
+
relaunch=True,
|
|
53
|
+
)
|
|
54
|
+
print(update_metadata["version"])
|
|
55
|
+
# The caller should now shut down so the detached helper can replace and relaunch the app.
|
|
52
56
|
```
|
|
53
57
|
|
|
54
58
|
The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
|
|
@@ -58,6 +62,7 @@ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-k
|
|
|
58
62
|
|
|
59
63
|
latest_version = client.search_for_update("1.2.3")
|
|
60
64
|
print(client.status) # connected
|
|
65
|
+
print(client.update_state["can_download"])
|
|
61
66
|
|
|
62
67
|
client.reload() # repeats the last update check
|
|
63
68
|
client.reload(api_key=None) # clears authentication and marks the client unauthenticated
|
|
@@ -24,13 +24,17 @@ from lange.distribution import DistributionClient
|
|
|
24
24
|
|
|
25
25
|
client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
latest = client.check_for_update("1.2.3")
|
|
28
|
+
if latest:
|
|
29
|
+
version, artifact_path = client.download(current_version="1.2.3")
|
|
30
|
+
print(version, artifact_path)
|
|
31
|
+
update_metadata = client.update(
|
|
32
|
+
current_version="1.2.3",
|
|
33
|
+
installed_app_path=Path("/Applications/Desktop App.app"),
|
|
34
|
+
relaunch=True,
|
|
35
|
+
)
|
|
36
|
+
print(update_metadata["version"])
|
|
37
|
+
# The caller should now shut down so the detached helper can replace and relaunch the app.
|
|
34
38
|
```
|
|
35
39
|
|
|
36
40
|
The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
|
|
@@ -40,6 +44,7 @@ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-k
|
|
|
40
44
|
|
|
41
45
|
latest_version = client.search_for_update("1.2.3")
|
|
42
46
|
print(client.status) # connected
|
|
47
|
+
print(client.update_state["can_download"])
|
|
43
48
|
|
|
44
49
|
client.reload() # repeats the last update check
|
|
45
50
|
client.reload(api_key=None) # clears authentication and marks the client unauthenticated
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import os
|
|
4
5
|
import mimetypes
|
|
6
|
+
import shutil
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
9
11
|
|
|
10
|
-
from ._update_macos import
|
|
12
|
+
from ._update_macos import (
|
|
13
|
+
extract_macos_app_from_zip,
|
|
14
|
+
read_macos_bundle_identifier,
|
|
15
|
+
spawn_macos_update_process,
|
|
16
|
+
)
|
|
11
17
|
from ._util import (
|
|
12
18
|
_compare_versions,
|
|
13
19
|
detect_distribution_os,
|
|
@@ -46,6 +52,9 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
46
52
|
self.timeout = timeout
|
|
47
53
|
self._last_checked_version: str | None = None
|
|
48
54
|
self._last_update_result: str | None = None
|
|
55
|
+
self._last_downloaded_version: str | None = None
|
|
56
|
+
self._last_downloaded_artifact_path: str | None = None
|
|
57
|
+
self._last_error: str | None = None
|
|
49
58
|
normalized_distribution_name = distribution_name.strip()
|
|
50
59
|
|
|
51
60
|
if not normalized_distribution_name:
|
|
@@ -55,6 +64,7 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
55
64
|
raise ValueError("check your distribution name again. spaces are not allowed by design")
|
|
56
65
|
|
|
57
66
|
self.distribution_name = normalized_distribution_name
|
|
67
|
+
self._refresh_capabilities()
|
|
58
68
|
|
|
59
69
|
@property
|
|
60
70
|
def status(self) -> str:
|
|
@@ -74,6 +84,22 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
74
84
|
"""
|
|
75
85
|
return self._last_checked_version
|
|
76
86
|
|
|
87
|
+
@property
|
|
88
|
+
def update_state(self) -> dict[str, Any]:
|
|
89
|
+
can_check = self.api_key is not None
|
|
90
|
+
can_download = self._last_update_result is not None and can_check
|
|
91
|
+
return {
|
|
92
|
+
"status": self._status,
|
|
93
|
+
"last_checked_version": self._last_checked_version,
|
|
94
|
+
"available_version": self._last_update_result,
|
|
95
|
+
"downloaded_version": self._last_downloaded_version,
|
|
96
|
+
"downloaded_artifact_path": self._last_downloaded_artifact_path,
|
|
97
|
+
"can_check_for_update": can_check,
|
|
98
|
+
"can_download": can_download,
|
|
99
|
+
"can_update": can_download or self._last_downloaded_artifact_path is not None,
|
|
100
|
+
"last_error": self._last_error,
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
def reload(self, api_key: str | None | object = _UNSET_API_KEY) -> None:
|
|
78
104
|
"""
|
|
79
105
|
Reload authentication and repeat the last update check when available.
|
|
@@ -82,18 +108,24 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
82
108
|
:returns: ``None``.
|
|
83
109
|
"""
|
|
84
110
|
super().reload(api_key=api_key)
|
|
111
|
+
self._last_error = None
|
|
85
112
|
|
|
86
113
|
if self.api_key is None:
|
|
87
114
|
self._last_update_result = None
|
|
115
|
+
self._refresh_capabilities()
|
|
88
116
|
return
|
|
89
117
|
|
|
90
118
|
if self._last_checked_version is None:
|
|
91
119
|
self._set_status("off")
|
|
120
|
+
self._refresh_capabilities()
|
|
92
121
|
return
|
|
93
122
|
|
|
94
|
-
self.
|
|
123
|
+
self.check_for_update(self._last_checked_version)
|
|
95
124
|
|
|
96
125
|
def search_for_update(self, current_version: str) -> str | None:
|
|
126
|
+
return self.check_for_update(current_version)
|
|
127
|
+
|
|
128
|
+
def check_for_update(self, current_version: str) -> str | None:
|
|
97
129
|
"""
|
|
98
130
|
Resolve the newest available version for the current OS when it is newer.
|
|
99
131
|
|
|
@@ -112,6 +144,8 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
112
144
|
|
|
113
145
|
if self.api_key is None:
|
|
114
146
|
self._set_status("unauthenticated")
|
|
147
|
+
self._last_error = "API key is required for this operation."
|
|
148
|
+
self._refresh_capabilities()
|
|
115
149
|
raise ValueError("API key is required for this operation.")
|
|
116
150
|
|
|
117
151
|
self._set_status("pending")
|
|
@@ -141,13 +175,17 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
141
175
|
newest_version = version
|
|
142
176
|
|
|
143
177
|
self._last_update_result = newest_version
|
|
178
|
+
self._last_error = None
|
|
144
179
|
self._set_status("connected")
|
|
180
|
+
self._refresh_capabilities()
|
|
145
181
|
return newest_version
|
|
146
|
-
except Exception:
|
|
182
|
+
except Exception as error:
|
|
147
183
|
if self.api_key is None:
|
|
148
184
|
self._set_status("unauthenticated")
|
|
149
185
|
else:
|
|
150
186
|
self._set_status("failed")
|
|
187
|
+
self._last_error = str(error)
|
|
188
|
+
self._refresh_capabilities()
|
|
151
189
|
raise
|
|
152
190
|
|
|
153
191
|
def _build_distribution_url(self) -> str:
|
|
@@ -209,22 +247,42 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
209
247
|
return newest_match
|
|
210
248
|
|
|
211
249
|
def download_to_temp(self) -> Path:
|
|
250
|
+
version, artifact_path = self.download()
|
|
251
|
+
_ = version
|
|
252
|
+
return artifact_path
|
|
253
|
+
|
|
254
|
+
def download(self, current_version: str | None = None, target_version: str | None = None) -> tuple[str, Path]:
|
|
212
255
|
"""
|
|
213
|
-
Download
|
|
256
|
+
Download one update artifact for the current OS into ``/tmp``.
|
|
214
257
|
|
|
215
|
-
:
|
|
258
|
+
:param current_version: Optional current installed version used to discover newer versions.
|
|
259
|
+
:param target_version: Optional explicit version to download.
|
|
260
|
+
:returns: Tuple of resolved version and downloaded artifact path.
|
|
216
261
|
:raises ValueError: If no downloadable artifact exists for the current OS.
|
|
217
262
|
:raises httpx.HTTPError: If the metadata or artifact download fails.
|
|
218
263
|
"""
|
|
219
264
|
current_os = detect_distribution_os()
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if
|
|
265
|
+
resolved_version: str | None = None
|
|
266
|
+
|
|
267
|
+
if target_version is not None:
|
|
268
|
+
resolved_version = target_version.strip()
|
|
269
|
+
elif current_version is not None:
|
|
270
|
+
resolved_version = self.check_for_update(current_version)
|
|
271
|
+
else:
|
|
272
|
+
newest_match = self._get_newest_version_artifact(current_os)
|
|
273
|
+
if newest_match is not None:
|
|
274
|
+
resolved_version = newest_match[0]
|
|
275
|
+
|
|
276
|
+
if not resolved_version:
|
|
223
277
|
raise ValueError(f"No downloadable versions available for operating system: {current_os}.")
|
|
224
278
|
|
|
225
|
-
version,
|
|
226
|
-
|
|
227
|
-
|
|
279
|
+
artifact_path = self._download_version_artifact_to_temp(version=resolved_version, os_name=current_os)
|
|
280
|
+
self._last_downloaded_version = resolved_version
|
|
281
|
+
self._last_downloaded_artifact_path = str(artifact_path)
|
|
282
|
+
self._last_error = None
|
|
283
|
+
self._refresh_capabilities()
|
|
284
|
+
self._cleanup_old_temp_versions()
|
|
285
|
+
return resolved_version, artifact_path
|
|
228
286
|
|
|
229
287
|
def update(
|
|
230
288
|
self,
|
|
@@ -234,6 +292,8 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
234
292
|
target_version: str | None = None,
|
|
235
293
|
current_process_id: int | None = None,
|
|
236
294
|
wait_for_exit_timeout: float = 30.0,
|
|
295
|
+
relaunch: bool = True,
|
|
296
|
+
strict_bundle_identifier: bool = True,
|
|
237
297
|
) -> dict[str, str]:
|
|
238
298
|
"""
|
|
239
299
|
Download and stage a macOS update, then spawn a detached helper to apply it.
|
|
@@ -256,7 +316,7 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
256
316
|
raise ValueError("current_version is required.")
|
|
257
317
|
|
|
258
318
|
normalized_installed_app_path = validate_installed_macos_app_path(installed_app_path)
|
|
259
|
-
resolved_target_version = target_version.strip() if target_version is not None else self.
|
|
319
|
+
resolved_target_version = target_version.strip() if target_version is not None else self.check_for_update(
|
|
260
320
|
normalized_current_version
|
|
261
321
|
)
|
|
262
322
|
|
|
@@ -267,16 +327,37 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
267
327
|
if artifact is None:
|
|
268
328
|
raise ValueError(f"No macos artifact is available for version {resolved_target_version}.")
|
|
269
329
|
|
|
270
|
-
artifact_path = self._download_version_artifact_to_temp(
|
|
330
|
+
artifact_path = self._download_version_artifact_to_temp(
|
|
331
|
+
version=resolved_target_version,
|
|
332
|
+
os_name="macos",
|
|
333
|
+
artifact=artifact,
|
|
334
|
+
)
|
|
271
335
|
extracted_app_path = extract_macos_app_from_zip(artifact_path)
|
|
336
|
+
if strict_bundle_identifier:
|
|
337
|
+
installed_bundle_identifier = read_macos_bundle_identifier(normalized_installed_app_path)
|
|
338
|
+
extracted_bundle_identifier = read_macos_bundle_identifier(extracted_app_path)
|
|
339
|
+
if (
|
|
340
|
+
installed_bundle_identifier is not None
|
|
341
|
+
and extracted_bundle_identifier is not None
|
|
342
|
+
and installed_bundle_identifier != extracted_bundle_identifier
|
|
343
|
+
):
|
|
344
|
+
raise ValueError(
|
|
345
|
+
"Downloaded app bundle identifier does not match installed application bundle identifier."
|
|
346
|
+
)
|
|
272
347
|
helper_process = spawn_macos_update_process(
|
|
273
348
|
source_app_path=extracted_app_path,
|
|
274
349
|
installed_app_path=normalized_installed_app_path,
|
|
275
350
|
current_process_id=os.getpid() if current_process_id is None else current_process_id,
|
|
276
351
|
workspace_path=artifact_path.parent,
|
|
277
352
|
wait_for_exit_timeout=wait_for_exit_timeout,
|
|
353
|
+
relaunch=relaunch,
|
|
278
354
|
)
|
|
279
355
|
process_id = helper_process.get("process_id", helper_process.get("pid", ""))
|
|
356
|
+
self._last_downloaded_version = resolved_target_version
|
|
357
|
+
self._last_downloaded_artifact_path = str(artifact_path)
|
|
358
|
+
self._last_error = None
|
|
359
|
+
self._refresh_capabilities()
|
|
360
|
+
self._cleanup_old_temp_versions()
|
|
280
361
|
|
|
281
362
|
return {
|
|
282
363
|
"version": resolved_target_version,
|
|
@@ -288,7 +369,12 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
288
369
|
"process_id": str(process_id),
|
|
289
370
|
}
|
|
290
371
|
|
|
291
|
-
def _download_version_artifact_to_temp(
|
|
372
|
+
def _download_version_artifact_to_temp(
|
|
373
|
+
self,
|
|
374
|
+
version: str,
|
|
375
|
+
os_name: str,
|
|
376
|
+
artifact: dict[str, Any] | None = None,
|
|
377
|
+
) -> Path:
|
|
292
378
|
"""
|
|
293
379
|
Download one explicit version artifact for one operating system into ``/tmp``.
|
|
294
380
|
|
|
@@ -298,12 +384,12 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
298
384
|
:raises ValueError: If the artifact metadata is incomplete or missing.
|
|
299
385
|
:raises httpx.HTTPError: If the metadata or artifact download fails.
|
|
300
386
|
"""
|
|
301
|
-
|
|
387
|
+
resolved_artifact = artifact if artifact is not None else self._get_version_artifact(version=version, os_name=os_name)
|
|
302
388
|
|
|
303
|
-
if
|
|
389
|
+
if resolved_artifact is None:
|
|
304
390
|
raise ValueError(f"No {os_name} artifact is available for version {version}.")
|
|
305
391
|
|
|
306
|
-
filename = str(
|
|
392
|
+
filename = str(resolved_artifact.get("filename", "")).strip()
|
|
307
393
|
|
|
308
394
|
if not filename:
|
|
309
395
|
raise ValueError("Artifact filename is required.")
|
|
@@ -321,11 +407,40 @@ class DistributionClient(BaseLangeLabsClient):
|
|
|
321
407
|
)
|
|
322
408
|
response.raise_for_status()
|
|
323
409
|
destination_path.write_bytes(response.content)
|
|
410
|
+
self._verify_downloaded_artifact(destination_path, resolved_artifact)
|
|
324
411
|
finally:
|
|
325
412
|
client.close()
|
|
326
413
|
|
|
327
414
|
return destination_path
|
|
328
415
|
|
|
416
|
+
def _verify_downloaded_artifact(self, artifact_path: Path, artifact_payload: dict[str, Any]) -> None:
|
|
417
|
+
checksum = str(
|
|
418
|
+
artifact_payload.get("sha256")
|
|
419
|
+
or artifact_payload.get("checksumSha256")
|
|
420
|
+
or artifact_payload.get("checksum")
|
|
421
|
+
or ""
|
|
422
|
+
).strip().lower()
|
|
423
|
+
if not checksum:
|
|
424
|
+
raise ValueError("Artifact checksum metadata is required for secure updates.")
|
|
425
|
+
|
|
426
|
+
digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
|
427
|
+
if digest.lower() != checksum:
|
|
428
|
+
raise ValueError("Downloaded artifact checksum validation failed.")
|
|
429
|
+
|
|
430
|
+
def _cleanup_old_temp_versions(self, retain_count: int = 3) -> None:
|
|
431
|
+
versions_root = Path("/tmp") / self.distribution_name / "versions"
|
|
432
|
+
if not versions_root.is_dir():
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
version_dirs = [path for path in versions_root.iterdir() if path.is_dir()]
|
|
436
|
+
version_dirs.sort(key=lambda directory: directory.stat().st_mtime, reverse=True)
|
|
437
|
+
for stale_path in version_dirs[retain_count:]:
|
|
438
|
+
shutil.rmtree(stale_path, ignore_errors=True)
|
|
439
|
+
|
|
440
|
+
def _refresh_capabilities(self) -> None:
|
|
441
|
+
# The update_state property is computed from current fields; this method keeps callers consistent.
|
|
442
|
+
return None
|
|
443
|
+
|
|
329
444
|
def _get_version_artifact(self, version: str, os_name: str) -> dict[str, Any] | None:
|
|
330
445
|
"""
|
|
331
446
|
Resolve the downloadable artifact payload for one version and operating system.
|
|
@@ -6,6 +6,7 @@ import shutil
|
|
|
6
6
|
import stat
|
|
7
7
|
import subprocess
|
|
8
8
|
import zipfile
|
|
9
|
+
import plistlib
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from shlex import quote
|
|
11
12
|
|
|
@@ -29,18 +30,15 @@ def extract_macos_app_from_zip(archive_path: str | Path) -> Path:
|
|
|
29
30
|
destination_path.mkdir(parents=True, exist_ok=True)
|
|
30
31
|
|
|
31
32
|
with zipfile.ZipFile(normalized_archive_path) as archive:
|
|
33
|
+
app_bundle_roots = _collect_zip_app_bundle_roots(archive)
|
|
34
|
+
if len(app_bundle_roots) != 1:
|
|
35
|
+
raise ValueError("macOS update archives must contain exactly one .app bundle.")
|
|
32
36
|
archive.extractall(destination_path)
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
]
|
|
39
|
-
|
|
40
|
-
if len(app_bundles) != 1:
|
|
41
|
-
raise ValueError("macOS update archives must contain exactly one top-level .app bundle.")
|
|
42
|
-
|
|
43
|
-
return app_bundles[0]
|
|
38
|
+
extracted_path = (destination_path / app_bundle_roots[0]).resolve()
|
|
39
|
+
if not extracted_path.is_dir() or extracted_path.suffix != ".app":
|
|
40
|
+
raise ValueError("macOS update archive extraction did not produce a valid .app bundle.")
|
|
41
|
+
return extracted_path
|
|
44
42
|
|
|
45
43
|
|
|
46
44
|
def spawn_macos_update_process(
|
|
@@ -50,6 +48,7 @@ def spawn_macos_update_process(
|
|
|
50
48
|
current_process_id: int,
|
|
51
49
|
workspace_path: str | Path,
|
|
52
50
|
wait_for_exit_timeout: float,
|
|
51
|
+
relaunch: bool = True,
|
|
53
52
|
) -> dict[str, str]:
|
|
54
53
|
"""
|
|
55
54
|
Generate and launch the detached macOS helper process that applies the update.
|
|
@@ -77,6 +76,7 @@ def spawn_macos_update_process(
|
|
|
77
76
|
backup_app_path=backup_app_path,
|
|
78
77
|
current_process_id=current_process_id,
|
|
79
78
|
wait_for_exit_timeout=wait_for_exit_timeout,
|
|
79
|
+
relaunch=relaunch,
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
script_path.write_text(script_contents, encoding="utf-8")
|
|
@@ -109,6 +109,7 @@ def _build_macos_update_script(
|
|
|
109
109
|
backup_app_path: Path,
|
|
110
110
|
current_process_id: int,
|
|
111
111
|
wait_for_exit_timeout: float,
|
|
112
|
+
relaunch: bool,
|
|
112
113
|
) -> str:
|
|
113
114
|
"""
|
|
114
115
|
Build the shell script that applies the macOS app replacement with rollback.
|
|
@@ -122,7 +123,7 @@ def _build_macos_update_script(
|
|
|
122
123
|
"""
|
|
123
124
|
timeout_seconds = max(int(wait_for_exit_timeout), 0)
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
script = f"""#!/bin/bash
|
|
126
127
|
set -euo pipefail
|
|
127
128
|
|
|
128
129
|
SOURCE_APP={quote(str(source_app_path))}
|
|
@@ -159,3 +160,32 @@ fi
|
|
|
159
160
|
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
|
|
160
161
|
rm -rf "$BACKUP_APP"
|
|
161
162
|
"""
|
|
163
|
+
if relaunch:
|
|
164
|
+
script += '\n/usr/bin/open "$TARGET_APP"\n'
|
|
165
|
+
return script
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _collect_zip_app_bundle_roots(archive: zipfile.ZipFile) -> list[Path]:
|
|
169
|
+
app_bundle_roots: set[Path] = set()
|
|
170
|
+
for member_name in archive.namelist():
|
|
171
|
+
parts = Path(member_name).parts
|
|
172
|
+
for index, part in enumerate(parts):
|
|
173
|
+
if part.endswith(".app"):
|
|
174
|
+
app_bundle_roots.add(Path(*parts[: index + 1]))
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
return sorted(app_bundle_roots, key=lambda bundle_path: len(bundle_path.parts))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_macos_bundle_identifier(app_path: str | Path) -> str | None:
|
|
181
|
+
info_plist_path = Path(app_path).expanduser().resolve() / "Contents" / "Info.plist"
|
|
182
|
+
if not info_plist_path.is_file():
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
with info_plist_path.open("rb") as info_plist_file:
|
|
186
|
+
payload = plistlib.load(info_plist_file)
|
|
187
|
+
|
|
188
|
+
bundle_identifier = payload.get("CFBundleIdentifier")
|
|
189
|
+
if not isinstance(bundle_identifier, str):
|
|
190
|
+
return None
|
|
191
|
+
return bundle_identifier.strip() or None
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import platform
|
|
5
|
+
import re
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
6
8
|
|
|
@@ -52,12 +54,12 @@ def _compare_versions(left: str, right: str) -> int:
|
|
|
52
54
|
:param right: Right-hand version string.
|
|
53
55
|
:returns: Positive when ``left`` is newer, negative when older, otherwise ``0``.
|
|
54
56
|
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
max_length = max(len(
|
|
57
|
+
left_core, left_pre = _normalize_version_parts(left)
|
|
58
|
+
right_core, right_pre = _normalize_version_parts(right)
|
|
59
|
+
max_length = max(len(left_core), len(right_core))
|
|
58
60
|
|
|
59
|
-
padded_left =
|
|
60
|
-
padded_right =
|
|
61
|
+
padded_left = left_core + (0,) * (max_length - len(left_core))
|
|
62
|
+
padded_right = right_core + (0,) * (max_length - len(right_core))
|
|
61
63
|
|
|
62
64
|
if padded_left > padded_right:
|
|
63
65
|
return 1
|
|
@@ -65,10 +67,41 @@ def _compare_versions(left: str, right: str) -> int:
|
|
|
65
67
|
if padded_left < padded_right:
|
|
66
68
|
return -1
|
|
67
69
|
|
|
70
|
+
if left_pre is None and right_pre is None:
|
|
71
|
+
return 0
|
|
72
|
+
if left_pre is None:
|
|
73
|
+
return 1
|
|
74
|
+
if right_pre is None:
|
|
75
|
+
return -1
|
|
76
|
+
|
|
77
|
+
max_pre_len = max(len(left_pre), len(right_pre))
|
|
78
|
+
for index in range(max_pre_len):
|
|
79
|
+
if index >= len(left_pre):
|
|
80
|
+
return -1
|
|
81
|
+
if index >= len(right_pre):
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
left_identifier = left_pre[index]
|
|
85
|
+
right_identifier = right_pre[index]
|
|
86
|
+
|
|
87
|
+
if left_identifier == right_identifier:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
left_is_number = left_identifier.isdigit()
|
|
91
|
+
right_is_number = right_identifier.isdigit()
|
|
92
|
+
|
|
93
|
+
if left_is_number and right_is_number:
|
|
94
|
+
return 1 if int(left_identifier) > int(right_identifier) else -1
|
|
95
|
+
if left_is_number and not right_is_number:
|
|
96
|
+
return -1
|
|
97
|
+
if not left_is_number and right_is_number:
|
|
98
|
+
return 1
|
|
99
|
+
return 1 if left_identifier > right_identifier else -1
|
|
100
|
+
|
|
68
101
|
return 0
|
|
69
102
|
|
|
70
103
|
|
|
71
|
-
def _normalize_version_parts(version: str) -> tuple[int, ...]:
|
|
104
|
+
def _normalize_version_parts(version: str) -> tuple[tuple[int, ...], tuple[str, ...] | None]:
|
|
72
105
|
"""
|
|
73
106
|
Convert a dotted numeric version string into comparable integer parts.
|
|
74
107
|
|
|
@@ -80,7 +113,9 @@ def _normalize_version_parts(version: str) -> tuple[int, ...]:
|
|
|
80
113
|
if not normalized_version:
|
|
81
114
|
raise ValueError("Version is required.")
|
|
82
115
|
|
|
83
|
-
|
|
116
|
+
version_without_build = normalized_version.split("+", 1)[0]
|
|
117
|
+
core_part, separator, prerelease_part = version_without_build.partition("-")
|
|
118
|
+
parts = core_part.split(".")
|
|
84
119
|
normalized_parts: list[int] = []
|
|
85
120
|
|
|
86
121
|
for part in parts:
|
|
@@ -89,7 +124,16 @@ def _normalize_version_parts(version: str) -> tuple[int, ...]:
|
|
|
89
124
|
|
|
90
125
|
normalized_parts.append(int(part))
|
|
91
126
|
|
|
92
|
-
|
|
127
|
+
prerelease_identifiers: tuple[str, ...] | None = None
|
|
128
|
+
if separator:
|
|
129
|
+
if not prerelease_part:
|
|
130
|
+
raise ValueError(f"Unsupported version format: {version}.")
|
|
131
|
+
tokens = prerelease_part.split(".")
|
|
132
|
+
if any(not token or not re.match(r"^[0-9A-Za-z-]+$", token) for token in tokens):
|
|
133
|
+
raise ValueError(f"Unsupported version format: {version}.")
|
|
134
|
+
prerelease_identifiers = tuple(tokens)
|
|
135
|
+
|
|
136
|
+
return tuple(normalized_parts), prerelease_identifiers
|
|
93
137
|
|
|
94
138
|
|
|
95
139
|
def validate_installed_macos_app_path(installed_app_path: str | Path) -> Path:
|
|
@@ -108,4 +152,10 @@ def validate_installed_macos_app_path(installed_app_path: str | Path) -> Path:
|
|
|
108
152
|
if not normalized_path.is_dir() or normalized_path.suffix != ".app":
|
|
109
153
|
raise ValueError("installed_app_path must point to an existing .app bundle.")
|
|
110
154
|
|
|
155
|
+
if not os.access(normalized_path, os.W_OK):
|
|
156
|
+
raise ValueError("installed_app_path is not writable for replacement.")
|
|
157
|
+
|
|
158
|
+
if not os.access(normalized_path.parent, os.W_OK):
|
|
159
|
+
raise ValueError("installed_app_path parent directory is not writable for replacement.")
|
|
160
|
+
|
|
111
161
|
return normalized_path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|