Esempio n. 1
0
class BaseAppFinder(abc.ABC):
    def __init__(self, get_close_matches=None, find_best_exe=None):
        self._pathfinder = PathFinder(CURRENT_SYSTEM)
        self.get_close_matches = get_close_matches or self._get_close_matches
        self.find_best_exe = find_best_exe or self._find_best_exe

    async def __call__(self, owned_title_id: Dict[str, str],
                       paths: Set[pathlib.Path]) -> Dict[str, LocalHumbleGame]:
        """
        :param owned_title_id: human_name: machine_name dictionary
        """
        start = time.time()
        found_games = await self._scan_folders(paths, set(owned_title_id))
        local_games = {
            owned_title_id[title]: LocalHumbleGame(owned_title_id[title], exe)
            for title, exe in found_games.items()
        }
        logging.debug(f'=== Scanning folders took {time.time() - start}')
        return local_games

    async def _scan_folders(self, paths: Iterable[Union[str, os.PathLike]],
                            app_names: Set[str]) -> Dict[str, pathlib.Path]:
        """
        :param paths: all master paths to be scan for app finding
        :param app_names:  app names to be matched with folder names
        :returns:      mapping of app names to found executables
        """
        not_yet_found: Set[str] = app_names.copy()
        result: Dict[str, pathlib.Path] = {}
        close_matches: Dict[str, pathlib.Path] = {}
        # exact matches
        for path in paths:
            async for app_name, exe in self.__scan(path,
                                                   not_yet_found,
                                                   similarity=1):
                result[app_name] = exe
        # close matches
        for path in paths:
            async for app_name, exe in self.__scan(path,
                                                   not_yet_found,
                                                   similarity=0.8):
                close_matches[app_name] = exe
        # overwrite close matches with exact results
        close_matches.update(result)
        return close_matches

    async def __scan(
            self, path: Union[str, os.PathLike], candidates: Set[str],
            similarity: float
    ) -> AsyncGenerator[Tuple[str, pathlib.Path], None]:
        """One level depth search generator for application execs based on similarity with candidate names.
        :param path:        root dir of which subdirectories will be scanned
        :param candidates:  set of app names used for exact and close matching with directory names
        :param similarity:  cutoff level for difflib.get_close_matches; set 1 for exact matching only
        :yields:            2-el. tuple of app_name and executable
        """
        root, dirs, _ = next(os.walk(path))
        logging.debug(
            f'New scan - similarity: {similarity}, candidates: {list(candidates)}'
        )
        for dir_name in dirs:
            await asyncio.sleep(0)
            matches = self.get_close_matches(dir_name, candidates, similarity)
            for app_name in matches:
                dir_path = pathlib.PurePath(root) / dir_name
                best_exe = self.find_best_exe(dir_path, app_name)
                if best_exe is None:
                    logging.warning(
                        'No executable found, moving to next best matched app')
                    continue
                candidates.remove(app_name)
                yield app_name, pathlib.Path(best_exe)
                break

    def _get_close_matches(self, dir_name: str, candidates: Set[str],
                           similarity: float) -> List[str]:
        """Wrapper around difflib.get_close_matches"""
        matches_ = difflib.get_close_matches(dir_name,
                                             candidates,
                                             cutoff=similarity)
        matches = cast(List[str],
                       matches_)  # as str is Sequence[str] - mypy/issues/5090
        if matches:
            logging.info(
                f'found close ({similarity}) matches for {dir_name}: {matches}'
            )
        return matches

    def _find_best_exe(self, dir_path: pathlib.PurePath, app_name: str):
        executables = self._pathfinder.find_executables(dir_path)
        if not executables:
            return None
        logging.debug(f'Found execs: {executables}')
        return self._pathfinder.choose_main_executable(app_name, executables)
class WindowsAppFinder:
    def __init__(self):
        self._reg = WinRegUninstallWatcher(ignore_filter=self.is_other_store_game)
        self._pathfinder = PathFinder(HP.WINDOWS)

    @staticmethod
    def is_other_store_game(key_name) -> bool:
        """Exclude Steam and GOG games using uninstall key name.
        In the future probably more DRM-free stores should be supported
        """
        match = re.match(r'\d{10}_is1', key_name)  # GOG.com
        if match:
            return True
        return "Steam App" in key_name

    @staticmethod
    def _matches(human_name: str, uk: UninstallKey) -> bool:
        def escape(x):
            return x.replace(':', '').lower()
        def escaped_matches(a, b):
            return escape(a) == escape(b)
        def norm(x):
            return x.replace(" III", " 3").replace(" II", " 2")

        if human_name == uk.display_name \
            or escaped_matches(human_name, uk.display_name) \
            or uk.key_name.lower().startswith(human_name.lower()):
            return True

        location = uk.install_location_path
        if location:
            if escaped_matches(human_name, location.name):
                return True
        else:
            location = uk.uninstall_string_path or uk.display_icon_path
            if location:
                if escaped_matches(human_name, location.parent.name):
                    return True

        # quickfix for Torchlight II ect., until better solution will be provided
        return escaped_matches(norm(human_name), norm(uk.display_name))

    def _find_executable(self, human_name: str, uk: UninstallKey) -> Optional[pathlib.Path]:
        """ Returns most probable app executable of given uk or None if not found.
        """
        # sometimes display_icon link to main executable
        upath = uk.uninstall_string_path
        ipath = uk.display_icon_path
        if ipath and ipath.suffix == '.exe':
            if ipath != upath and 'unins' not in str(ipath):  # exclude uninstaller
                return ipath

        # get install_location if present; if not, check for uninstall or display_icon parents
        location = uk.install_location_path \
            or (upath.parent if upath else None) \
            or (ipath.parent if ipath else None)

        # find all executables and get best machting (exclude uninstall_path)
        if location and location.exists():
            executables = set(self._pathfinder.find_executables(location)) - {str(upath)}
            best_match = self._pathfinder.choose_main_executable(human_name, executables)
            if best_match is None:
                logging.warning(f'Main exe not found for {human_name}; \
                    loc: {uk.install_location}; up: {upath}; ip: {ipath}; execs: {executables}')
                return None
            return pathlib.Path(best_match)
        return None

    async def find_local_games(self, owned_games: List[HumbleGame]) -> List[LocalHumbleGame]:
        local_games = []
        while self._reg.uninstall_keys:
            uk = self._reg.uninstall_keys.pop()
            try:
                for og in owned_games:
                    if isinstance(og, Key):
                        continue
                    if self._matches(og.human_name, uk):
                        exe = self._find_executable(og.human_name, uk)
                        if exe is not None:
                            game = LocalHumbleGame(og.machine_name, exe, uk.uninstall_string)
                            logging.info(f'New local game found: {game}')
                            local_games.append(game)
                            break
                        logging.warning(f"Uninstall key matched, but cannot find \
                            game exe for [{og.human_name}]; uk: {uk}")
            except Exception:
                self._reg.uninstall_keys.add(uk)
                raise
            await asyncio.sleep(0.001)  # makes this method non blocking
        return local_games


    def refresh(self):
        self._reg.refresh()