Skip to content

Commit 29d5a7e

Browse files
committed
feat(macOS): Implement check-for-updates and auto-update
1 parent 7ea7028 commit 29d5a7e

File tree

5 files changed

+865
-77
lines changed

5 files changed

+865
-77
lines changed

ARCHITECTURE_1_software_update.md

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ This ensures users always have access to the latest features, bug fixes, and sec
3131

3232
4. **Installation Process**
3333
- ✅ Windows: Creates batch file to run installer after app exit with integrity validation
34-
- ✅ Linux/macOS: Uses pip to install updated package (supports wheel assets from releases)
34+
- ✅ macOS: Downloads `.dmg`, mounts it with `hdiutil`, copies `.app` to `/Applications` with `ditto`, then unmounts
35+
- ✅ Linux: Uses pip/uv to install updated package from PyPI
3536
- ✅ File integrity validation (size, magic bytes, SHA256 when available)
3637
- ✅ Automatic cleanup of invalid/corrupted downloads
3738

@@ -114,8 +115,11 @@ Legend:
114115
- `get_release_info()`: GitHub API communication with rate limit handling
115116
- `get_expected_sha256_from_release()`: SHA256 checksum retrieval from release assets
116117
- `download_file_from_url()`: File download with retry, resume, and progress tracking
117-
- `download_and_install_on_windows()`: Windows installer with integrity validation
118-
- `download_and_install_pip_release()`: Linux/macOS pip installation
118+
- `_validate_github_url()`: Shared URL whitelist validation (GitHub HTTPS only)
119+
- `_validate_download_file()`: Shared file size, optional magic bytes, and permission validation
120+
- `download_and_install_on_windows()`: Windows `.exe` installer with PE header and integrity validation
121+
- `download_and_install_on_macos()`: macOS `.dmg` installer — mount, copy `.app`, unmount
122+
- `download_and_install_pip_release()`: Linux pip/uv installation from PyPI
119123

120124
- **Actual Dependencies**:
121125
- `requests` with SSL verification and proxy support ✅
@@ -151,15 +155,17 @@ Legend:
151155

152156
5. **Download and Installation Phase**
153157
- **Windows**: Downloads .exe installer with SHA256 verification, validates PE header, creates batch file
154-
- **Linux/macOS**: Attempts wheel asset from GitHub first (with SHA256), falls back to pip
158+
- **macOS**: Downloads `.dmg` with SHA256 verification, mounts with `hdiutil`, copies `.app` bundle to `/Applications` using `ditto`, then unmounts
159+
- **Linux**: Installs via pip/uv from PyPI
155160
- ✅ Progress callback provides real-time feedback during download
156161
- ✅ SHA256 integrity validation of downloaded files (when checksums available)
157162
- ✅ File format validation (PE headers for .exe, ZIP headers for .whl)
158163
- ✅ Automatic cleanup of invalid downloads
159164

160165
6. **Application Exit for Update**
161166
- Windows: Main application exits (`os._exit(0)`) to allow installer to run
162-
- Linux/macOS: Application continues after pip installation completes
167+
- macOS: Application continues after DMG installation completes; user must restart manually
168+
- Linux: Application continues after pip installation completes
163169
- Update process returns True to signal main application to exit
164170

165171
### Integration Points
@@ -168,7 +174,8 @@ Legend:
168174
-**Logging System**: Uses standard Python logging for all update activities and errors
169175
-**File System**:
170176
- Windows: Uses `tempfile.TemporaryDirectory()` for secure temporary file handling
171-
- Both platforms: Manages installer/package downloads and cleanup
177+
- macOS: Uses `tempfile.TemporaryDirectory()`; mounts/unmounts DMG via `hdiutil`; copies `.app` via `ditto`
178+
- Linux: Manages pip/uv package installation
172179
-**Internet Backend**: Uses `backend_internet.py` for GitHub API communication and file downloads
173180
-**Frontend Components**: Uses `BaseWindow` and `ScrollFrame` for consistent UI behavior
174181

@@ -187,7 +194,7 @@ Legend:
187194
-**Retry Logic**: Automatic retry with exponential backoff and jitter (3 attempts by default)
188195
-**Download Corruption**: SHA256 validation, file size checks, and magic byte verification
189196
-**Resume Capability**: Partial downloads can be resumed using HTTP Range headers
190-
-**Installation Failures**: Exception handling with logging for Windows and pip installation failures
197+
-**Installation Failures**: Exception handling with logging for Windows, macOS DMG, and pip installation failures
191198
-**Permission Errors**: Catches specific exceptions (`OSError`, `PermissionError`, `NotImplementedError`)
192199
-**User Feedback**: Clear error messages logged at appropriate levels (error/debug)
193200
-**Resource Cleanup**: Automatic cleanup of invalid/corrupted downloads with error logging
@@ -202,7 +209,7 @@ Legend:
202209
-**Mock Testing**: Extensive mocking of network operations and external dependencies
203210
-**Error Path Testing**: Tests for various error conditions and exception handling
204211
-**UI Testing**: Frontend dialog tests with proper setup and teardown
205-
-**Platform Testing**: Tests for Windows, Linux, and macOS specific code paths
212+
-**Platform Testing**: Tests for Windows, Linux, and macOS specific code paths including DMG mounting/installation helpers
206213
-**Version Handling**: Tests for prerelease versions, malformed tags, and edge cases
207214
- ⚠️ **Security Testing**: File validation tests (size, magic bytes) but no malicious payload testing
208215
-**Real Network Testing**: Integration tests with actual GitHub API calls (marked with `@pytest.mark.integration`)
@@ -245,7 +252,7 @@ tests/unit_frontend_tkinter_software_update.py # UI dialog tests ✅
245252

246253
- `requests` with timeout, SSL verification, and proxy support
247254
- `hashlib` for SHA256 computation
248-
- `subprocess` for Windows installer and pip operations
255+
- `subprocess` for Windows batch file execution, macOS `hdiutil`/`ditto`, and pip operations
249256
- `tempfile` for secure temporary file handling
250257
- `contextlib` for exception suppression
251258
- `random` for retry backoff jitter
@@ -268,3 +275,16 @@ tests/unit_frontend_tkinter_software_update.py # UI dialog tests ✅
268275
- **Rationale**: Full PE validation would require additional dependencies (e.g., `pefile` library)
269276
- **Mitigation**: SHA256 checksum verification provides primary integrity protection
270277
- **Risk Assessment**: Low - checksums and GitHub HTTPS provide adequate security for typical use cases
278+
279+
2. **DMG Validation (macOS)**
280+
- **Current Implementation**: Validates file size and sets permissions only; no DMG magic bytes check
281+
- **Limitation**: Does not inspect the DMG binary header or quarantine flags
282+
- **Rationale**: macOS Gatekeeper and SHA256 checksums provide primary integrity protection
283+
- **Mitigation**: SHA256 checksum verification and GitHub HTTPS downloads
284+
- **Risk Assessment**: Low - equivalent protection level to the Windows implementation
285+
286+
3. **macOS Restart**
287+
- **Current Implementation**: User must manually restart the application after macOS DMG installation
288+
- **Limitation**: Unlike Windows (which auto-restarts via batch file), macOS does not auto-relaunch
289+
- **Rationale**: Relaunching from within a newly-installed `.app` requires additional complexity
290+
- **Risk Assessment**: Minor usability gap; user is informed via progress message

ardupilot_methodic_configurator/backend_internet.py

Lines changed: 185 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from logging import shutdown as logging_shutdown
2626
from logging import warning as logging_warning
2727
from typing import Any, Callable, Optional, Union
28-
from urllib.parse import urljoin
28+
from urllib.parse import urljoin, urlparse
2929
from webbrowser import open as webbrowser_open
3030

3131
import certifi
@@ -333,13 +333,22 @@ def get_expected_sha256_from_release(release_info: dict[str, Any], filename: str
333333
return None
334334

335335

336-
def _validate_windows_installer_url(url: str) -> bool:
337-
"""Validate that URL is from a trusted GitHub source."""
338-
# This whitelist is intentionally restrictive for security reasons
339-
if url.startswith("https://github.com/"):
340-
return True
341-
logging_error(_("Windows installer URL must be from github.com: %s"), url)
342-
return False
336+
def _validate_github_url(url: str) -> bool:
337+
"""Validate that URL is from the trusted GitHub releases download path."""
338+
# Parse and normalise before checking — guards against leading/trailing whitespace,
339+
# scheme case differences, and github.com appearing only in the path.
340+
parsed = urlparse(url.strip())
341+
if parsed.scheme.lower() != "https":
342+
logging_error(_("Installer URL must use HTTPS: %s"), url)
343+
return False
344+
if parsed.hostname != "github.com":
345+
logging_error(_("Installer URL must be from github.com: %s"), url)
346+
return False
347+
expected_prefix = "/ArduPilot/MethodicConfigurator/releases/download/"
348+
if not parsed.path.startswith(expected_prefix):
349+
logging_error(_("Installer URL must point to an ArduPilot/MethodicConfigurator release asset: %s"), url)
350+
return False
351+
return True
343352

344353

345354
def _verify_installer_integrity(path: str, expected_sha256: Optional[str]) -> bool:
@@ -357,32 +366,33 @@ def _verify_installer_integrity(path: str, expected_sha256: Optional[str]) -> bo
357366
return True
358367

359368

360-
def _validate_windows_installer_file(path: str) -> bool:
361-
"""Validate installer file size, PE signature, and set secure permissions."""
369+
def _validate_download_file(path: str, magic_bytes: Optional[bytes] = None, magic_error_msg: str = "") -> bool:
370+
"""Validate downloaded file size, optional magic bytes signature, and set secure permissions."""
362371
try:
363372
st = os.stat(path)
364373
if st.st_size < 1024:
365-
logging_error(_("Downloaded installer too small: %d bytes"), st.st_size)
374+
logging_error(_("Downloaded file too small: %d bytes"), st.st_size)
366375
with contextlib.suppress(OSError, FileNotFoundError):
367376
os.remove(path)
368377
return False
369378

370-
with open(path, "rb") as _fh:
371-
sig = _fh.read(2)
372-
# see ARCHITECTURE_1_software_update.md for rationale of this simplified test
373-
if sig != PE_MAGIC_BYTES:
374-
logging_error(_("Downloaded installer does not appear to be a Windows executable"))
375-
with contextlib.suppress(OSError, FileNotFoundError):
376-
os.remove(path)
377-
return False
379+
if magic_bytes is not None:
380+
with open(path, "rb") as _fh:
381+
sig = _fh.read(len(magic_bytes))
382+
# see ARCHITECTURE_1_software_update.md for rationale of this simplified test
383+
if sig != magic_bytes:
384+
logging_error("%s", magic_error_msg or _("Downloaded file has unexpected format"))
385+
with contextlib.suppress(OSError, FileNotFoundError):
386+
os.remove(path)
387+
return False
378388

379389
# Try to restrict permissions where supported
380390
with contextlib.suppress(PermissionError, OSError, NotImplementedError):
381391
os.chmod(path, 0o600)
382392

383393
return True
384394
except OSError as e:
385-
logging_error(_("Failed to validate downloaded installer: %s"), e)
395+
logging_error(_("Failed to validate downloaded file: %s"), e)
386396
with contextlib.suppress(OSError, FileNotFoundError):
387397
os.remove(path)
388398
return False
@@ -454,7 +464,7 @@ def download_and_install_on_windows(
454464
455465
"""
456466
# Validate URL is from trusted GitHub source
457-
if not _validate_windows_installer_url(download_url):
467+
if not _validate_github_url(download_url):
458468
return False
459469

460470
logging_info(_("Downloading and installing new version for Windows..."))
@@ -478,7 +488,11 @@ def download_and_install_on_windows(
478488
return False
479489

480490
# Validate file size, PE signature, and set secure permissions
481-
if not _validate_windows_installer_file(temp_path):
491+
if not _validate_download_file(
492+
temp_path,
493+
magic_bytes=PE_MAGIC_BYTES,
494+
magic_error_msg=str(_("Downloaded installer does not appear to be a Windows executable")),
495+
):
482496
return False
483497

484498
if progress_callback:
@@ -497,6 +511,155 @@ def download_and_install_on_windows(
497511
return False
498512

499513

514+
def _mount_dmg(dmg_path: str) -> Optional[str]:
515+
"""Mount a DMG file using hdiutil and return the mount point path."""
516+
try:
517+
result = subprocess.run( # noqa: S603
518+
["hdiutil", "attach", dmg_path, "-nobrowse", "-noautoopen"], # noqa: S607
519+
capture_output=True,
520+
text=True,
521+
check=True,
522+
)
523+
# hdiutil output lines are tab-separated: <device>\t[fstype]\t<mountpoint>
524+
# The number of tab-separated fields varies; scan all parts for a /Volumes/ path.
525+
for line in result.stdout.strip().splitlines():
526+
parts = [p.strip() for p in line.split("\t")]
527+
for part in parts:
528+
if part.startswith("/Volumes/"):
529+
return part
530+
logging_error(_("Could not parse mount point from hdiutil output: %s"), result.stdout)
531+
except subprocess.CalledProcessError as e:
532+
logging_error(_("Failed to mount DMG: %s"), e)
533+
except OSError as e:
534+
logging_error(_("Failed to run hdiutil: %s"), e)
535+
return None
536+
537+
538+
def _install_app_from_mount(mount_point: str, progress_callback: Optional[Callable[[float, str], None]] = None) -> bool:
539+
"""Copy .app bundle from DMG mount point to /Applications using ditto."""
540+
try:
541+
apps = [os.path.join(mount_point, f) for f in os.listdir(mount_point) if f.endswith(".app")]
542+
if not apps:
543+
logging_error(_("No .app bundle found in DMG at %s"), mount_point)
544+
return False
545+
546+
app_path = apps[0]
547+
app_name = os.path.basename(app_path)
548+
dest = os.path.join("/Applications", app_name)
549+
550+
if progress_callback:
551+
progress_callback(60.0, _("Installing {}...").format(app_name))
552+
553+
# Use ditto to preserve metadata, resource forks, and extended attributes
554+
subprocess.check_call(["ditto", app_path, dest]) # noqa: S603, S607
555+
556+
if progress_callback:
557+
progress_callback(80.0, _("App installed"))
558+
559+
return True
560+
except subprocess.CalledProcessError as e:
561+
logging_error(_("Failed to install app from DMG: %s"), e)
562+
except OSError as e:
563+
logging_error(_("Error during app installation: %s"), e)
564+
return False
565+
566+
567+
def _unmount_dmg(mount_point: str) -> None:
568+
"""Unmount a DMG volume using hdiutil detach."""
569+
with contextlib.suppress(subprocess.CalledProcessError, OSError):
570+
subprocess.run( # noqa: S603
571+
["hdiutil", "detach", mount_point, "-force"], # noqa: S607
572+
capture_output=True,
573+
check=False,
574+
)
575+
576+
577+
def _mount_and_install_dmg(
578+
temp_path: str,
579+
progress_callback: Optional[Callable[[float, str], None]] = None,
580+
) -> bool:
581+
"""Mount the DMG at temp_path, install the .app to /Applications, then unmount."""
582+
if progress_callback:
583+
progress_callback(40.0, _("Mounting DMG..."))
584+
585+
mount_point = _mount_dmg(temp_path)
586+
if not mount_point:
587+
return False
588+
589+
try:
590+
result = _install_app_from_mount(mount_point, progress_callback)
591+
finally:
592+
_unmount_dmg(mount_point)
593+
594+
if result:
595+
if progress_callback:
596+
progress_callback(100.0, _("Installation complete. Please restart the application."))
597+
logging_info(_("macOS DMG installation complete. Please restart the application."))
598+
return result
599+
600+
601+
def download_and_install_on_macos(
602+
download_url: str,
603+
file_name: str,
604+
progress_callback: Optional[Callable[[float, str], None]] = None,
605+
expected_sha256: Optional[str] = None,
606+
) -> bool:
607+
"""
608+
Download and install a new version of the application on macOS via DMG.
609+
610+
This function orchestrates the complete update process: URL validation, download,
611+
integrity check, DMG mount, app copy, and unmount.
612+
613+
Args:
614+
download_url: The URL from which to download the DMG installer
615+
file_name: The name to save the downloaded file as
616+
progress_callback: Optional callback function to report progress
617+
Takes two arguments: progress (0.0-100.0) and status message
618+
expected_sha256: Optional SHA256 hex digest expected for the downloaded DMG. If provided,
619+
the downloaded file will be verified and the install will be aborted on mismatch.
620+
621+
Returns:
622+
bool: True if installation completed successfully, False otherwise
623+
624+
"""
625+
if not _validate_github_url(download_url):
626+
return False
627+
628+
logging_info(_("Downloading and installing new version for macOS..."))
629+
630+
try:
631+
with tempfile.TemporaryDirectory() as temp_dir:
632+
temp_path = os.path.join(temp_dir, file_name)
633+
634+
# Download with progress updates
635+
if not download_file_from_url(
636+
download_url,
637+
temp_path,
638+
timeout=60, # Increased timeout for large files
639+
progress_callback=progress_callback,
640+
):
641+
logging_error(_("Failed to download DMG from %s"), download_url)
642+
return False
643+
644+
# Verify SHA256 checksum if provided
645+
if progress_callback:
646+
progress_callback(0.0, _("Verifying checksum..."))
647+
if not _verify_installer_integrity(temp_path, expected_sha256):
648+
return False
649+
650+
# Validate file size and set secure permissions (no magic bytes check for DMG)
651+
if progress_callback:
652+
progress_callback(20.0, _("Validating file..."))
653+
if not _validate_download_file(temp_path):
654+
return False
655+
656+
return _mount_and_install_dmg(temp_path, progress_callback)
657+
658+
except OSError as e:
659+
logging_error(_("File operation failed: {}").format(e))
660+
return False
661+
662+
500663
def download_and_install_pip_release(progress_callback: Optional[Callable[[float, str], None]] = None) -> int:
501664
"""Download and install the latest release via pip/uv from PyPI."""
502665
if progress_callback:

0 commit comments

Comments
 (0)