1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import abc 8import urllib.parse 9from typing import (TYPE_CHECKING, Dict, Final, Iterable, Optional, Tuple, Type, 10 Union) 11 12from crossbench.browsers.downloader import DMGArchiveHelper, Downloader 13from crossbench.browsers.firefox.version import FirefoxVersion 14 15if TYPE_CHECKING: 16 from crossbench.browsers.version import BrowserVersion 17 from crossbench.path import AnyPathLike, LocalPath 18 from crossbench.plt.base import Platform 19 20 21_PLATFORM_NAME_LOOKUP: Final[Dict[Tuple[str, str], str]] = { 22 ("win", "ia32"): "win32", 23 ("win", "x64"): "win64", 24 ("win", "arm64"): "win-aarch64", 25 ("linux", "x64"): "linux-x86-64", 26 ("linux", "ia32"): "linux-i686", 27 ("macos", "x64"): "mac", 28 ("macos", "arm64"): "mac" 29} 30 31 32class FirefoxDownloader(Downloader): 33 # TODO: support nightly versions as well 34 STORAGE_URL: str = "https://ftp.mozilla.org/pub/firefox/releases/" 35 36 @classmethod 37 def _get_loader_cls(cls, 38 browser_platform: Platform) -> Type[FirefoxDownloader]: 39 if browser_platform.is_macos: 40 return FirefoxDownloaderMacOS 41 if browser_platform.is_linux: 42 return FirefoxDownloaderLinux 43 if browser_platform.is_win: 44 return FirefoxDownloaderWin 45 raise ValueError("Downloading Firefox is not supported " 46 f"{browser_platform.name} {browser_platform.machine}") 47 48 @classmethod 49 def is_valid_version(cls, path_or_identifier: str) -> bool: 50 return FirefoxVersion.is_valid_unique(path_or_identifier) 51 52 @classmethod 53 def _is_valid(cls, path_or_identifier: AnyPathLike, 54 browser_platform: Platform) -> bool: 55 if cls.is_valid_version(str(path_or_identifier)): 56 return True 57 path = browser_platform.path(path_or_identifier) 58 return (browser_platform.exists(path) and 59 path.name.endswith(cls.ARCHIVE_SUFFIX)) 60 61 def __init__(self, version_identifier: Union[str, 62 LocalPath], browser_type: str, 63 platform_name: str, browser_platform: Platform): 64 assert not browser_type 65 assert not platform_name 66 firefox_platform_name = _PLATFORM_NAME_LOOKUP.get(browser_platform.key) 67 if not firefox_platform_name: 68 raise ValueError( 69 "Unsupported macOS architecture for downloading Firefox: " 70 f"got={browser_platform.machine}") 71 super().__init__(version_identifier, "firefox", firefox_platform_name, 72 browser_platform) 73 74 def _parse_version(self, version_identifier: str) -> BrowserVersion: 75 return FirefoxVersion.parse(version_identifier) 76 77 def _requested_version_validation(self) -> None: 78 pass 79 80 def _find_archive_url(self) -> Tuple[BrowserVersion, Optional[str]]: 81 # Quick probe for complete versions 82 if self._requested_version.is_complete: 83 return self._find_exact_archive_url() 84 raise NotImplementedError("Only full-release versions supported.") 85 86 def _find_exact_archive_url(self) -> Tuple[BrowserVersion, Optional[str]]: 87 folder_url = ( 88 f"{self.STORAGE_URL}{self._requested_version.parts_str}/mac/en-GB") 89 return tuple(self._archive_urls(folder_url, self._requested_version))[0] 90 91 def _download_archive(self, archive_url: str, tmp_dir: LocalPath) -> None: 92 self._browser_platform.download_to( 93 archive_url, tmp_dir / f"archive.{self.ARCHIVE_SUFFIX}") 94 archive_candidates = list(tmp_dir.glob("*")) 95 assert len(archive_candidates) == 1, ( 96 f"Download tmp dir contains more than one file: {tmp_dir}" 97 f"{archive_candidates}") 98 candidate = archive_candidates[0] 99 assert not self._archive_path.exists(), ( 100 f"Archive was already downloaded: {self._archive_path}") 101 candidate.replace(self._archive_path) 102 103 @abc.abstractmethod 104 def _install_archive(self, archive_path: LocalPath) -> None: 105 pass 106 107 108class FirefoxDownloaderLinux(FirefoxDownloader): 109 ARCHIVE_SUFFIX: str = ".tar.bz2" 110 111 @classmethod 112 def is_valid(cls, path_or_identifier: AnyPathLike, 113 browser_platform: Platform) -> bool: 114 return cls._is_valid(path_or_identifier, browser_platform) 115 116 def _installed_app_path(self) -> LocalPath: 117 # TODO: support local vs remote 118 return self._extracted_path() / "firefox-bin" 119 120 def _archive_urls( 121 self, folder_url: str, 122 version: BrowserVersion) -> Iterable[Tuple[BrowserVersion, str]]: 123 return ((version, f"{folder_url}/firefox-{version.parts_str}.tar.bz2"),) 124 125 def _install_archive(self, archive_path: LocalPath) -> None: 126 raise NotImplementedError("Missing linux support") 127 128 129class FirefoxDownloaderMacOS(FirefoxDownloader): 130 ARCHIVE_SUFFIX: str = ".dmg" 131 MIN_MAC_ARM64_MILESTONE: Final[int] = 84 132 133 @classmethod 134 def is_valid(cls, path_or_identifier: AnyPathLike, 135 browser_platform: Platform) -> bool: 136 return cls._is_valid(path_or_identifier, browser_platform) 137 138 def _requested_version_validation(self) -> None: 139 major_version: int = self._requested_version.major 140 if (self._browser_platform.is_macos and self._browser_platform.is_arm64 and 141 major_version < self.MIN_MAC_ARM64_MILESTONE): 142 raise ValueError( 143 "Native Mac arm64/m1 Firefox version is available with v84, " 144 f"but requested {major_version}.") 145 146 def _download_archive(self, archive_url: str, tmp_dir: LocalPath) -> None: 147 assert self._browser_platform.is_macos 148 if self._browser_platform.is_arm64 and (self._requested_version 149 < self.MIN_MAC_ARM64_MILESTONE): 150 raise ValueError( 151 "Firefox Arm64 Apple Silicon is only available starting with " 152 f"{self.MIN_MAC_ARM64_MILESTONE}, " 153 f"but requested {self._requested_version} is too old.") 154 super()._download_archive(archive_url, tmp_dir) 155 156 def _archive_urls( 157 self, folder_url: str, 158 version: BrowserVersion) -> Iterable[Tuple[BrowserVersion, str]]: 159 archive_name = urllib.parse.quote(f"Firefox {version.parts_str}.dmg") 160 return ((version, f"{folder_url}/{archive_name}"),) 161 162 def _extracted_path(self) -> LocalPath: 163 # TODO: support local vs remote 164 return self._installed_app_path() 165 166 def _installed_app_path(self) -> LocalPath: 167 return self._out_dir / f"Firefox {self._requested_version}.app" 168 169 def _install_archive(self, archive_path: LocalPath) -> None: 170 extracted_path = self._extracted_path() 171 DMGArchiveHelper.extract(self.host_platform, archive_path, extracted_path) 172 assert extracted_path.exists() 173 174 175class FirefoxDownloaderWin(FirefoxDownloader): 176 177 @classmethod 178 def is_valid(cls, path_or_identifier: AnyPathLike, 179 browser_platform: Platform) -> bool: 180 return False 181 182 def _install_archive(self, archive_path: LocalPath) -> None: 183 raise NotImplementedError("Missing windows support") 184