├── .github └── FUNDING.yml ├── packaging ├── Fuji.png ├── Fuji.icns ├── screenshot.png ├── supporters │ ├── fuji-video.png │ └── 13Cubed.svg ├── Full Disk Access Settings.url ├── Fuji.sh ├── dmgbuild.py └── LICENSE.rtf ├── meta.py ├── requirements.txt ├── checks ├── abstract.py ├── name.py ├── network.py ├── folders.py └── free_space.py ├── shared └── utils.py ├── acquisition ├── asr.py ├── rsync.py ├── sysdiagnose.py └── abstract.py ├── Fuji.spec ├── .gitignore ├── README.md ├── fuji.py └── LICENSE.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: thelazza 2 | -------------------------------------------------------------------------------- /packaging/Fuji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazza/Fuji/HEAD/packaging/Fuji.png -------------------------------------------------------------------------------- /packaging/Fuji.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazza/Fuji/HEAD/packaging/Fuji.icns -------------------------------------------------------------------------------- /packaging/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazza/Fuji/HEAD/packaging/screenshot.png -------------------------------------------------------------------------------- /meta.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.1.0" 2 | AUTHOR = "Andrea Lazzarotto" 3 | HOMEPAGE = "https://andrealazzarotto.com" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dmgbuild[badge_icons]==1.6.1 2 | humanize==4.9.0 3 | pyinstaller==6.6.0 4 | wxPython==4.2.0 5 | -------------------------------------------------------------------------------- /packaging/supporters/fuji-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazza/Fuji/HEAD/packaging/supporters/fuji-video.png -------------------------------------------------------------------------------- /packaging/Full Disk Access Settings.url: -------------------------------------------------------------------------------- 1 | [InternetShortcut] 2 | URL=x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles 3 | -------------------------------------------------------------------------------- /packaging/Fuji.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | if [ $(id -u) -eq 0 ]; then 6 | ./Fuji.bin 7 | else 8 | security execute-with-privileges "./Fuji.bin" 9 | fi 10 | -------------------------------------------------------------------------------- /checks/abstract.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | 4 | from acquisition.abstract import Parameters 5 | 6 | 7 | @dataclass 8 | class CheckResult: 9 | passed: bool = True 10 | message: str = "" 11 | 12 | def write(self, content: str): 13 | if self.message: 14 | self.message = self.message + "\n" + content 15 | else: 16 | self.message = content 17 | 18 | 19 | class Check(ABC): 20 | name = "Abstract check" 21 | 22 | @abstractmethod 23 | def execute(self, params: Parameters) -> CheckResult: 24 | pass 25 | -------------------------------------------------------------------------------- /shared/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from typing import List, Tuple 3 | 4 | 5 | def lines_to_properties(lines: List[str], separator=":", strip_chars=None) -> dict: 6 | result = {} 7 | 8 | for line in filter((lambda x: separator in x), lines): 9 | key, value = line.split(separator, 1) 10 | result[key.strip(strip_chars)] = value.strip(strip_chars) 11 | 12 | return result 13 | 14 | 15 | def command_to_properties( 16 | arguments: List[str], separator=":", strip_chars=None 17 | ) -> dict: 18 | output = subprocess.check_output(arguments, universal_newlines=True) 19 | return lines_to_properties(output.splitlines(), separator, strip_chars) 20 | -------------------------------------------------------------------------------- /packaging/dmgbuild.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | # This will be called from the parent directory 5 | project_directory = Path(os.getcwd()) 6 | dist_directory = project_directory / "dist" 7 | pack_directory = project_directory / "packaging" 8 | 9 | # File names 10 | settings_file = "Full Disk Access Settings.url" 11 | fuji_app_file = "Fuji.app" 12 | license_file = "LICENSE.rtf" 13 | 14 | files = [ 15 | str(pack_directory / settings_file), 16 | str(dist_directory / fuji_app_file), 17 | str(pack_directory / license_file), 18 | ] 19 | icon_locations = { 20 | settings_file: (128, 128), 21 | fuji_app_file: (320, 128), 22 | license_file: (512, 128), 23 | } 24 | badge_icon = str(pack_directory / "Fuji.icns") 25 | left_bottom_coordinates = (200, 300) 26 | width_height = (640, 480) 27 | window_rect = (left_bottom_coordinates, width_height) 28 | -------------------------------------------------------------------------------- /checks/name.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from acquisition.abstract import Parameters 4 | from checks.abstract import Check, CheckResult 5 | 6 | 7 | class NameCheck(Check): 8 | name = "Name check" 9 | 10 | def execute(self, params: Parameters) -> CheckResult: 11 | special_extensions = { 12 | ".app", 13 | ".bundle", 14 | ".logarchive", 15 | ".pkg", 16 | ".sparsebundle", 17 | ".workflow", 18 | ".xpc", 19 | } 20 | result = CheckResult(passed=True) 21 | 22 | # Get extension from image name 23 | _, ext = os.path.splitext(params.image_name) 24 | ext = ext.lower() 25 | 26 | if ext in special_extensions: 27 | result.passed = False 28 | result.write( 29 | f'Special extension "{ext}" shall not be used in the image name!' 30 | ) 31 | else: 32 | result.write(f"The image name is valid") 33 | 34 | return result 35 | -------------------------------------------------------------------------------- /checks/network.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from acquisition.abstract import Parameters 4 | from checks.abstract import Check, CheckResult 5 | 6 | 7 | class NetworkCheck(Check): 8 | name = "Network check" 9 | 10 | def execute(self, params: Parameters) -> CheckResult: 11 | result = CheckResult() 12 | 13 | # This is the CDN server used by the 'networkquality' command 14 | apple_server = "mensura.cdn-apple.com" 15 | 16 | try: 17 | http_test = subprocess.check_output( 18 | ["nc", "-z", apple_server, "80", "-G1"], 19 | stderr=subprocess.STDOUT, 20 | universal_newlines=True, 21 | ) 22 | connected = "succeeded!" in http_test 23 | except: 24 | connected = False 25 | 26 | if connected: 27 | result.write("This Mac is connected to the Internet!") 28 | result.passed = False 29 | else: 30 | result.write("This Mac is not connected to the Internet") 31 | result.passed = True 32 | 33 | return result 34 | -------------------------------------------------------------------------------- /acquisition/asr.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from acquisition.abstract import AcquisitionMethod, Parameters, Report 3 | 4 | 5 | class AsrMethod(AcquisitionMethod): 6 | name = "ASR" 7 | description = """Apple Software Restore logical acquisition. 8 | This is the recommended option, but it works only for volumes.""" 9 | 10 | def execute(self, params: Parameters) -> Report: 11 | # Prepare report 12 | report = Report(params, self, start_time=datetime.now()) 13 | report.path_details = self._gather_path_info(params.source) 14 | report.hardware_info = self._gather_hardware_info() 15 | 16 | success = self._create_temporary_image(report) 17 | if not success: 18 | return report 19 | 20 | print("\nASR", params.source, "->", self.temporary_volume) 21 | command = [ 22 | "asr", 23 | "restore", 24 | "--source", 25 | f"{params.source}", 26 | "--target", 27 | self.temporary_volume, 28 | "--noprompt", 29 | "--erase", 30 | ] 31 | status, output = self._run_process(command) 32 | 33 | # Sometimes ASR crashes at the end but the acquisition is still OK 34 | success = status == 0 or ( 35 | output.count("..100") > 1 and "Restored target" in output 36 | ) 37 | 38 | if not success: 39 | return report 40 | 41 | return self._dmg_and_hash(report) 42 | -------------------------------------------------------------------------------- /Fuji.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | import importlib 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | from shutil import copy, move 8 | 9 | sys.path.insert(0, ".") 10 | meta = importlib.import_module("meta") 11 | 12 | 13 | a = Analysis( 14 | ["fuji.py"], 15 | pathex=[], 16 | binaries=[], 17 | datas=[], 18 | hiddenimports=[], 19 | hookspath=[], 20 | hooksconfig={}, 21 | runtime_hooks=[], 22 | excludes=[], 23 | noarchive=False, 24 | optimize=0, 25 | ) 26 | pyz = PYZ(a.pure) 27 | 28 | exe = EXE( 29 | pyz, 30 | a.scripts, 31 | [], 32 | exclude_binaries=True, 33 | name="Fuji", 34 | debug=False, 35 | bootloader_ignore_signals=False, 36 | strip=False, 37 | upx=True, 38 | console=False, 39 | disable_windowed_traceback=False, 40 | argv_emulation=False, 41 | target_arch="universal2", 42 | codesign_identity=None, 43 | entitlements_file=None, 44 | icon=["packaging/Fuji.icns"], 45 | ) 46 | coll = COLLECT( 47 | exe, 48 | a.binaries, 49 | a.datas, 50 | strip=False, 51 | upx=True, 52 | upx_exclude=[], 53 | name="Fuji", 54 | ) 55 | app = BUNDLE( 56 | coll, 57 | name="Fuji.app", 58 | icon="./packaging/Fuji.icns", 59 | bundle_identifier="com.andrealazzarotto.fuji", 60 | version=meta.VERSION, 61 | ) 62 | 63 | executable_path = Path("./dist/Fuji.app/Contents/MacOS") 64 | move(executable_path / "Fuji", executable_path / "Fuji.bin") 65 | copy("./packaging/Fuji.sh", executable_path / "Fuji") 66 | 67 | dmg_path = "./dist/FujiApp.dmg" 68 | print("Building", dmg_path) 69 | result = subprocess.call( 70 | ["dmgbuild", "-s", "./packaging/dmgbuild.py", "FujiApp", dmg_path] 71 | ) 72 | if result == 0: 73 | print("Done") 74 | else: 75 | print("Failed!!!") 76 | -------------------------------------------------------------------------------- /checks/folders.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from acquisition.abstract import Parameters 4 | from checks.abstract import Check, CheckResult 5 | 6 | 7 | class FoldersCheck(Check): 8 | name = "Folders check" 9 | 10 | def execute(self, params: Parameters) -> CheckResult: 11 | result = CheckResult(passed=True) 12 | 13 | source_is_directory = os.path.isdir(params.source) 14 | if not source_is_directory: 15 | result.write("Source is not a directory!") 16 | result.passed = False 17 | 18 | same_path = params.tmp == params.destination 19 | 20 | tmp_is_directory = os.path.isdir(params.tmp) 21 | destination_is_directory = os.path.isdir(params.destination) 22 | 23 | tmp_path = params.tmp / params.image_name 24 | tmp_busy = os.path.exists(tmp_path) 25 | destination_path = params.destination / params.image_name 26 | destination_busy = os.path.exists(destination_path) 27 | 28 | if not same_path: 29 | if not tmp_is_directory: 30 | result.write("Temp image location is not a directory!") 31 | result.passed = False 32 | elif tmp_busy: 33 | result.write( 34 | f"Temp image location already contains {params.image_name}!" 35 | ) 36 | result.passed = False 37 | else: 38 | result.write("Temp image location is a valid directory") 39 | 40 | if not destination_is_directory: 41 | result.write("Destination is not a directory!") 42 | result.passed = False 43 | elif destination_busy: 44 | result.write(f"Destination already contains {params.image_name}!") 45 | result.passed = False 46 | else: 47 | result.write("Destination is a valid directory") 48 | 49 | return result 50 | -------------------------------------------------------------------------------- /acquisition/rsync.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pathlib import Path 3 | from typing import List 4 | 5 | from acquisition.abstract import AcquisitionMethod, Parameters, Report 6 | 7 | 8 | class RsyncMethod(AcquisitionMethod): 9 | name = "Rsync" 10 | description = """Files and directories are copied using Rsync. 11 | This is slower but it can be used on any source directory. Errors are ignored.""" 12 | 13 | def _compute_exclusions(self, params: Parameters) -> List[Path]: 14 | # Rsync can be tricked into acquiring files multiple times by macOS, due 15 | # to how it handles mount points inside the APFS container. This method 16 | # aims to prevent acquiring duplicates of the same files. 17 | 18 | _, mount_points = self._run_silent(["mount"]) 19 | lines = mount_points.splitlines() 20 | 21 | source_info = self._gather_path_info(params.source) 22 | source_disk = source_info.disk_parent 23 | 24 | results = [] 25 | for line in lines: 26 | if not (line.startswith("/dev/disk") and " on " in line): 27 | continue 28 | device = line.split(" on ")[0] 29 | point = line.split(" on ")[1].split("(")[0].strip() 30 | point_path = Path(point) 31 | point_disk = self._disk_from_device(device) 32 | 33 | if point_disk == source_disk and params.source in point_path.parents: 34 | results.append(point_path) 35 | 36 | return results 37 | 38 | def execute(self, params: Parameters) -> Report: 39 | # Prepare report 40 | report = Report(params, self, start_time=datetime.now()) 41 | report.path_details = self._gather_path_info(params.source) 42 | report.hardware_info = self._gather_hardware_info() 43 | 44 | print("Computing exclusions...") 45 | exclusions = self._compute_exclusions(params) 46 | 47 | success = self._create_temporary_image(report) 48 | if not success: 49 | return report 50 | 51 | print("\nRsync", params.source, "->", self.temporary_mount) 52 | source_str = f"{params.source}" 53 | if not source_str.endswith("/"): 54 | source_str = source_str + "/" 55 | command = ["rsync", "-xrlptgoEv", "--progress"] 56 | for exclusion in exclusions: 57 | command.extend(["--exclude", f"{exclusion}/"]) 58 | command.extend([source_str, self.temporary_mount]) 59 | status = self._run_status(command) 60 | 61 | # We cannot rely on the exit code, because it will probably contain some 62 | # errors if a few files cannot be copied. 63 | if status != 0: 64 | print(f"Rsync terminated (with status {status})") 65 | else: 66 | print("Rsync terminated") 67 | 68 | return self._dmg_and_hash(report) 69 | -------------------------------------------------------------------------------- /checks/free_space.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import humanize 4 | from acquisition.abstract import Parameters 5 | from checks.abstract import Check, CheckResult 6 | 7 | 8 | class FreeSpaceCheck(Check): 9 | name = "Free space check" 10 | 11 | def _get_used_space(self, path): 12 | try: 13 | statvfs = os.statvfs(path) 14 | except FileNotFoundError: 15 | return 0 16 | total_space = statvfs.f_blocks * statvfs.f_frsize 17 | free_space = statvfs.f_bfree * statvfs.f_frsize 18 | return total_space - free_space 19 | 20 | def _get_free_space(self, path): 21 | try: 22 | statvfs = os.statvfs(path) 23 | except FileNotFoundError: 24 | return 0 25 | free_space = statvfs.f_bfree * statvfs.f_frsize 26 | return free_space 27 | 28 | def execute(self, params: Parameters) -> CheckResult: 29 | result = CheckResult() 30 | 31 | source_used = self._get_used_space(params.source) 32 | tmp_string = f"{params.tmp}" 33 | destination_string = f"{params.destination}" 34 | same_volume = tmp_string.startswith( 35 | destination_string 36 | ) or destination_string.startswith(tmp_string) 37 | 38 | if same_volume: 39 | destination_free = self._get_free_space(params.destination) 40 | result.passed = destination_free >= 2 * source_used 41 | 42 | needed_readable = humanize.naturalsize(2 * source_used) 43 | free_readable = humanize.naturalsize(destination_free) 44 | tail = f"(up to {needed_readable} / {free_readable})" 45 | if result.passed: 46 | result.write(f"Free space in destination seems enough {tail}") 47 | else: 48 | result.write(f"Free space in destination could be insufficient {tail}") 49 | 50 | else: 51 | tmp_free = self._get_free_space(params.tmp) 52 | tmp_passed = tmp_free and tmp_free >= source_used 53 | tmp_needed_readable = humanize.naturalsize(source_used) 54 | tmp_free_readable = humanize.naturalsize(tmp_free) 55 | 56 | destination_free = self._get_free_space(params.destination) 57 | destination_passed = destination_free and destination_free >= source_used 58 | destination_needed_readable = humanize.naturalsize(source_used) 59 | destination_free_readable = humanize.naturalsize(destination_free) 60 | 61 | result.passed = tmp_passed and destination_passed 62 | tmp_tail = f"(up to {tmp_needed_readable} / {tmp_free_readable})" 63 | if tmp_passed: 64 | result.write( 65 | f"Free space in temp image location seems enough {tmp_tail}" 66 | ) 67 | else: 68 | result.write( 69 | f"Free space in temp image location could be insufficient {tmp_tail}" 70 | ) 71 | 72 | destination_tail = ( 73 | f"(up to {destination_needed_readable} / {destination_free_readable})" 74 | ) 75 | if destination_passed: 76 | result.write( 77 | f"Free space in destination seems enough {destination_tail}" 78 | ) 79 | else: 80 | result.write( 81 | f"Free space in destination could be insufficient {destination_tail}" 82 | ) 83 | 84 | return result 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | #*.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | # End of https://www.toptal.com/developers/gitignore/api/python 177 | -------------------------------------------------------------------------------- /packaging/supporters/13Cubed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acquisition/sysdiagnose.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sqlite3 3 | import time 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from acquisition.abstract import AcquisitionMethod, Parameters, Report 8 | 9 | 10 | class SysdiagnoseMethod(AcquisitionMethod): 11 | name = "Sysdiagnose" 12 | description = """System logs and configuration. 13 | This only acquires system data and unified logs (converted to SQLite).""" 14 | 15 | def _write_log_line(self, line: str, cursor: sqlite3.Cursor) -> None: 16 | data = json.loads(line) 17 | 18 | backtrace_frames = data.get("backtrace", {}).get("frames", []) 19 | cursor.execute( 20 | """ 21 | INSERT INTO system_logs ( 22 | timestamp, timezoneName, messageType, eventType, source, formatString, userID, 23 | activityIdentifier, subsystem, category, threadID, senderImageUUID, imageOffset, 24 | imageUUID, bootUUID, processImagePath, senderImagePath, machTimestamp, eventMessage, 25 | processImageUUID, traceID, processID, senderProgramCounter, parentActivityIdentifier 26 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 27 | """, 28 | ( 29 | data.get("timestamp"), 30 | data.get("timezoneName"), 31 | data.get("messageType"), 32 | data.get("eventType"), 33 | data.get("source"), 34 | data.get("formatString"), 35 | data.get("userID"), 36 | data.get("activityIdentifier"), 37 | data.get("subsystem"), 38 | data.get("category"), 39 | data.get("threadID"), 40 | data.get("senderImageUUID"), 41 | ( 42 | str(backtrace_frames[0].get("imageOffset")) 43 | if len(backtrace_frames) 44 | else None 45 | ), 46 | ( 47 | backtrace_frames[0].get("imageUUID") 48 | if len(backtrace_frames) 49 | else None 50 | ), 51 | data.get("bootUUID"), 52 | data.get("processImagePath"), 53 | data.get("senderImagePath"), 54 | str(data.get("machTimestamp")), 55 | data.get("eventMessage"), 56 | data.get("processImageUUID"), 57 | str(data.get("traceID")), 58 | str(data.get("processID")), 59 | str(data.get("senderProgramCounter")), 60 | data.get("parentActivityIdentifier"), 61 | ), 62 | ) 63 | 64 | def _convert_logs( 65 | self, logarchive_path: Path, database_file: Path, buffer_size=1024000 66 | ) -> int: 67 | # Create the database 68 | connection = sqlite3.connect(f"{database_file}") 69 | cursor = connection.cursor() 70 | 71 | # Set PRAGMA journal_mode to WAL (faster) 72 | cursor.execute("PRAGMA journal_mode=WAL;") 73 | 74 | # Values marked with "*" are numbers, but we use TEXT because sometimes 75 | # they are too large to fit in a SQLite integer value 76 | cursor.execute( 77 | """ 78 | CREATE TABLE IF NOT EXISTS system_logs ( 79 | timestamp TEXT, 80 | timezoneName TEXT, 81 | messageType TEXT, 82 | eventType TEXT, 83 | source TEXT, 84 | formatString TEXT, 85 | userID INTEGER, 86 | activityIdentifier INTEGER, 87 | subsystem TEXT, 88 | category TEXT, 89 | threadID INTEGER, 90 | senderImageUUID TEXT, 91 | imageOffset TEXT, -- * 92 | imageUUID TEXT, 93 | bootUUID TEXT, 94 | processImagePath TEXT, 95 | senderImagePath TEXT, 96 | machTimestamp TEXT, -- * 97 | eventMessage TEXT, 98 | processImageUUID TEXT, 99 | traceID TEXT, -- * 100 | processID TEXT, -- * 101 | senderProgramCounter TEXT, -- * 102 | parentActivityIdentifier INTEGER 103 | ) 104 | """ 105 | ) 106 | 107 | # Run log collect 108 | command = [ 109 | "log", 110 | "show", 111 | "--info", 112 | "--debug", 113 | "--signpost", 114 | "--style", 115 | "ndjson", 116 | "--archive", 117 | f"{logarchive_path}", 118 | ] 119 | p = self._create_shell_process(command) 120 | 121 | while True: 122 | time.sleep(0.1) 123 | 124 | lines = p.stdout.readlines(buffer_size) 125 | for line in lines: 126 | self._write_log_line(line, cursor) 127 | print(".", end="") 128 | connection.commit() 129 | 130 | if p.poll() != None: 131 | lines = p.stdout.readlines() 132 | for line in lines: 133 | self._write_log_line(line, cursor) 134 | print(".", end="") 135 | connection.commit() 136 | break 137 | 138 | print("\n\nCreating indexes...") 139 | for column in ( 140 | "timestamp", 141 | "messageType", 142 | "eventType", 143 | "userID", 144 | "activityIdentifier", 145 | "processID", 146 | "parentActivityIdentifier", 147 | ): 148 | cursor.execute(f"CREATE INDEX idx_{column} ON system_logs({column});") 149 | 150 | connection.commit() 151 | connection.close() 152 | 153 | return p.returncode 154 | 155 | def execute(self, params: Parameters) -> Report: 156 | # Prepare report 157 | report = Report(params, self, start_time=datetime.now()) 158 | report.path_details = self._gather_path_info(params.source) 159 | report.hardware_info = self._gather_hardware_info() 160 | # Write preliminary report 161 | self._write_report(report) 162 | 163 | success = self._create_temporary_image(report) 164 | if not success: 165 | return report 166 | 167 | sysdiagnose_destination = Path(self.temporary_mount) 168 | folder_name = "sysdiagnose_fuji" 169 | mount_point = self._find_mount_point(params.source) 170 | 171 | print("\nRunning sysdiagnose -> ", sysdiagnose_destination) 172 | command = [ 173 | "sysdiagnose", 174 | "-f", 175 | f"{sysdiagnose_destination}", 176 | "-A", 177 | f"{folder_name}", 178 | "-n", 179 | "-u", 180 | "-b", 181 | "-V", 182 | f"{mount_point}", 183 | ] 184 | status = self._run_status(command) 185 | 186 | if not status == 0: 187 | return report 188 | 189 | folder_path = sysdiagnose_destination / folder_name 190 | logarchive_path = folder_path / "system_logs.logarchive" 191 | database_file = sysdiagnose_destination / "system_logs.db" 192 | 193 | print("\nRunning log show -> ", logarchive_path) 194 | status = self._convert_logs(logarchive_path, database_file) 195 | 196 | if not status == 0: 197 | return report 198 | 199 | return self._dmg_and_hash(report) 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
4 |
5 |
16 |
17 | |
40 |
41 |
42 |
46 | 13Cubed |
51 |
91 |
92 |
93 | _[Getting Started with Fuji - The Logical Choice for Mac Imaging](https://www.youtube.com/watch?v=9bEiizjySHA) on YouTube_
94 |
95 | ### Important notes
96 |
97 | 1. Before starting the acquisition, you must specify on what drive(s) you want
98 | to store the temporary sparseimage and the final DMG file. Both values are
99 | `/Volumes/Fuji` by default and the _image name_ parameter will be used to make
100 | a new directory inside those locations.
101 |
102 | 2. You must not save the disk images on the same drive you are acquiring!
103 |
104 | 3. If you want to use the Rsync mode, it is recommended to **close all other
105 | applications before proceeding, especially Apple Mail,** otherwise some data
106 | might not be collected.
107 |
108 | 4. After the acquisition is completed you are free to decide if you want to
109 | delete the temporary sparseimage file, or keep it. All the data is still kept
110 | in the DMG file.
111 |
112 |
113 | ## Troubleshooting common issues
114 |
115 | ### ASR acquisition fails with "operation not permitted"
116 |
117 | First of all, ensure that Fuji is in the list of apps with _Full Disk Access_
118 | permissions and the toggle is active. Close and re-open Fuji.
119 |
120 | If the issue persists, try to acquire the _Data_ volume instead of the root
121 | volume. It is usually called **Macintosh HD - Data** and it includes all user
122 | files, settings and installed applications.
123 |
124 | Fuji testers have reported that this generally solves the issue.
125 |
126 | ### ASR acquisition fails with error 49186 or 49197
127 |
128 | This has often been reported on macOS version 13 (Ventura). The APFS volume may
129 | need to be checked using the _First Aid_ function of Disk Utility (`fsck`).
130 |
131 | If this does not work, try acquiring the **Macintosh HD - Data** volume instead.
132 |
133 | In some extreme cases you might need to upgrade the operating system to a newer
134 | version or perform Rsync acquisition instead.
135 |
136 | The Rsync acquisition method works even on damaged file systems and can be used
137 | to acquire only a single directory instead of the whole drive. Files that cannot
138 | be read are skipped.
139 |
140 | ### Apple Mail data is not being acquired in Rsync mode
141 |
142 | Please ensure all other apps are closed, especially Apple Mail, before using the
143 | Rsync acquisition method.
144 |
145 |
146 | ## Development
147 |
148 | Fuji is developed as a Universal2 application using the **3.10 release** of
149 | Python from [Python.org][python].
150 |
151 | You can create a virtual environment with:
152 |
153 | /usr/local/bin/python3.10 -m venv env
154 | source env/bin/activate
155 |
156 | The DMG file can be built by using the included Pyinstaller script:
157 |
158 | pip install -r requirements.txt
159 | pyinstaller Fuji.spec
160 |
161 | The build process must be executed from a computer running macOS.
162 |
163 | The README file in RTF format can be generated with pandoc:
164 |
165 | cat README.md | grep -v 'badge-chip' | pandoc -f markdown -s -o dist/README.rtf
166 |
167 | The following is a list of prerequisites if you want to modify the source code
168 | or run Fuji from source:
169 |
170 | - macOS version 11 or later
171 | - Python version 3.10 (tested with [3.10.11][python310])
172 |
173 |
174 | ## Resources
175 |
176 | These are a few of several resources that have helped in the development of this
177 | software. Some include further reading on the topic:
178 |
179 | - The question [How do I copy a list of folders recursively, ignoring
180 | errors?][superuser_question] has a couple of interesting leads, mentioning
181 | Rsync and Ditto.
182 | - An answer to [Can I use ditto on OS X to sync two folders on the same
183 | machine?][superuser_answer] summarizes the difference between using Ditto and
184 | Rsync, taken from the following article.
185 | - The [Guide to Backing Up Mac OS X][bombich_guide] by CCC's developer Mike
186 | Bombich includes a detailed description of Ditto, Rsync and ASR (with the
187 | purpose of creating full disk backups).
188 | - [A user’s guide to Disk Images][disk_images] describes the features of sparse
189 | bundles and sparse images.
190 |
191 |
192 | [releases]: https://github.com/Lazza/Fuji/releases
193 | [python]: https://python.org
194 | [python310]: https://www.python.org/downloads/release/python-31011/
195 | [superuser_question]: https://superuser.com/q/91556/278831
196 | [superuser_answer]: https://superuser.com/a/92142/278831
197 | [bombich_guide]: https://web.archive.org/web/20100107194426/http://www.bombich.com/mactips/image.html
198 | [disk_images]: https://eclecticlight.co/2022/07/11/a-users-guide-to-disk-images/
199 |
--------------------------------------------------------------------------------
/acquisition/abstract.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import os
3 | import re
4 | import selectors
5 | import shlex
6 | import subprocess
7 | import sys
8 | import time
9 | from abc import ABC, abstractmethod
10 | from dataclasses import dataclass, field
11 | from datetime import datetime
12 | from pathlib import Path
13 | from subprocess import Popen
14 | from typing import IO, List, Tuple
15 |
16 | from meta import AUTHOR, VERSION
17 | from shared.utils import command_to_properties, lines_to_properties
18 |
19 |
20 | @dataclass
21 | class Parameters:
22 | case: str = ""
23 | examiner: str = ""
24 | notes: str = ""
25 | image_name: str = "Mac_Acquisition"
26 | source: Path = Path("/")
27 | tmp: Path = Path("/Volumes/Fuji")
28 | destination: Path = Path("/Volumes/Fuji")
29 | sound: bool = True
30 |
31 |
32 | @dataclass
33 | class PathDetails:
34 | path: Path
35 | is_disk: bool = True
36 | disk_sectors: int = 0
37 | disk_device: str = ""
38 | disk_parent: str = ""
39 | disk_identifier: int = 0
40 | disk_info: str = ""
41 | filesystem: str = ""
42 |
43 |
44 | @dataclass
45 | class HashedFile:
46 | path: Path
47 | md5: str = ""
48 | sha1: str = ""
49 | sha256: str = ""
50 |
51 |
52 | @dataclass
53 | class Report:
54 | parameters: Parameters
55 | method: "AcquisitionMethod"
56 | start_time: datetime = None
57 | end_time: datetime = None
58 | path_details: PathDetails = None
59 | hardware_info: str = ""
60 | success: bool = False
61 | output_files: List[Path] = field(default_factory=list)
62 | result: HashedFile = None
63 |
64 |
65 | class AcquisitionMethod(ABC):
66 | name = "Abstract method"
67 | description = "This method cannot be used directly"
68 |
69 | temporary_path: Path = None
70 | temporary_container: str = None
71 | temporary_volume: str = None
72 | temporary_mount: str = None
73 | output_path: Path = None
74 |
75 | def _limited_read(self, file: IO[str], limit: int, encoding: str) -> str:
76 | sel = selectors.DefaultSelector()
77 | sel.register(file, selectors.EVENT_READ)
78 |
79 | events = sel.select(0.125)
80 | if events:
81 | data = os.read(file.fileno(), limit)
82 | return data.decode(encoding, "ignore")
83 | else:
84 | # Timeout occurred
85 | return ""
86 |
87 | def _create_shell_process(
88 | self, arguments: List[str], awake=True, tee: Path = None
89 | ) -> Popen[str]:
90 | if awake:
91 | arguments = ["caffeinate", "-dimsu"] + arguments
92 |
93 | command = shlex.join(arguments) + " 2>&1"
94 | if tee is not None:
95 | tail = shlex.join(["tee", f"{tee}"])
96 | command = f"{command} | {tail}"
97 |
98 | p = subprocess.Popen(
99 | command,
100 | stdout=subprocess.PIPE,
101 | shell=True,
102 | universal_newlines=True,
103 | )
104 | return p
105 |
106 | def _run_silent(self, arguments: List[str], awake=True) -> Tuple[int, str]:
107 | # Run a process silently. Return its status code and output.
108 | if awake:
109 | arguments = ["caffeinate", "-dimsu"] + arguments
110 |
111 | p = subprocess.run(arguments, capture_output=True, universal_newlines=True)
112 | return p.returncode, p.stdout
113 |
114 | def _run_process(
115 | self, arguments: List[str], awake=True, buffer_size=1024000, tee: Path = None
116 | ) -> Tuple[int, str]:
117 | # Run a process in plain sight. Return its status code and output.
118 | p = self._create_shell_process(arguments, awake=awake, tee=tee)
119 |
120 | encoding = p.stdout.encoding
121 | output = ""
122 | while True:
123 | # Let it breathe and avoid the UI getting stuck
124 | time.sleep(0.1)
125 | out = self._limited_read(p.stdout, buffer_size, encoding)
126 | if out:
127 | sys.stdout.write(out)
128 | output = output + out
129 |
130 | if p.poll() != None:
131 | out = p.stdout.read()
132 | sys.stdout.write(out)
133 | output = output + out
134 | break
135 |
136 | return p.returncode, output
137 |
138 | def _run_status(
139 | self, arguments: List[str], awake=True, buffer_size=1024000, tee: Path = None
140 | ) -> int:
141 | # Run a process in plain sight. Return its status code.
142 | p = self._create_shell_process(arguments, awake=awake, tee=tee)
143 |
144 | encoding = p.stdout.encoding
145 | while True:
146 | # Let it breathe and avoid the UI getting stuck
147 | time.sleep(0.1)
148 | out = self._limited_read(p.stdout, buffer_size, encoding)
149 | if out:
150 | sys.stdout.write(out)
151 |
152 | if p.poll() != None:
153 | out = p.stdout.read()
154 | sys.stdout.write(out)
155 | break
156 |
157 | return p.returncode
158 |
159 | def _disk_from_device(self, device: str) -> str:
160 | if not device.startswith("/dev/disk"):
161 | return device
162 | chunk = device[9:].split("s")[0]
163 | return "/dev/disk" + chunk
164 |
165 | def _find_mount_point(self, path: Path) -> Path:
166 | path = os.path.realpath(path)
167 | while not os.path.ismount(path):
168 | path = os.path.dirname(path)
169 | return path
170 |
171 | def _gather_path_info(self, path: Path) -> PathDetails:
172 | is_disk = os.path.ismount(path)
173 | disk_stats = os.statvfs(path)
174 | sectors = int(disk_stats.f_blocks * disk_stats.f_frsize / 512)
175 |
176 | disk_device = ""
177 | if is_disk:
178 | disk_info = subprocess.check_output(
179 | ["diskutil", "info", f"{path}"], universal_newlines=True
180 | )
181 | diskutil_info = lines_to_properties(disk_info.splitlines())
182 |
183 | valid = "Device Node" in diskutil_info
184 | if valid:
185 | disk_device = diskutil_info["Device Node"]
186 | else:
187 | is_disk = False
188 | filesystem = diskutil_info.get("Type (Bundle)", "")
189 | else:
190 | mount_point = self._find_mount_point(path)
191 | mount_info = self._gather_path_info(mount_point)
192 | disk_device = mount_info.disk_device
193 | disk_info = mount_info.disk_info
194 | filesystem = mount_info.filesystem
195 |
196 | disk_identifier = os.stat(path).st_dev
197 |
198 | details = PathDetails(
199 | path,
200 | is_disk=is_disk,
201 | disk_sectors=sectors,
202 | disk_device=disk_device,
203 | disk_parent=self._disk_from_device(disk_device),
204 | disk_identifier=disk_identifier,
205 | disk_info=disk_info,
206 | filesystem=filesystem,
207 | )
208 | return details
209 |
210 | def _gather_hardware_info(self) -> str:
211 | _, hardware_info = self._run_silent(
212 | [
213 | "system_profiler",
214 | "SPSoftwareDataType",
215 | "SPHardwareDataType",
216 | "SPNVMeDataType",
217 | "SPSerialATADataType",
218 | "SPParallelATADataType",
219 | ]
220 | )
221 | return hardware_info
222 |
223 | def _create_temporary_image(self, report: Report) -> bool:
224 | params = report.parameters
225 | output_directory = params.tmp / params.image_name
226 | output_directory.mkdir(parents=True, exist_ok=True)
227 |
228 | best_filesystem = "HFS+"
229 | if report.path_details.filesystem == "apfs":
230 | best_filesystem = "APFS"
231 |
232 | # Add a bit of extra space to ensure the destination is large enough
233 | extra_gigabyte_sectors = 2 * 10**6
234 | sectors = report.path_details.disk_sectors + extra_gigabyte_sectors
235 | self.temporary_path = output_directory / f"{params.image_name}.sparseimage"
236 |
237 | image_path: str = f"{self.temporary_path}"
238 | self.temporary_container = None
239 | self.temporary_volume = None
240 | result, output = self._run_process(
241 | [
242 | "hdiutil",
243 | "create",
244 | "-sectors",
245 | f"{sectors}",
246 | "-fs",
247 | best_filesystem,
248 | "-volname",
249 | params.image_name,
250 | image_path,
251 | ],
252 | )
253 | if result > 0:
254 | return False
255 |
256 | result, output = self._run_process(["hdiutil", "attach", image_path])
257 | output_lines = output.strip().splitlines()
258 |
259 | container_lines = [
260 | line for line in output_lines if line.startswith("/dev/disk")
261 | ]
262 | volume_lines = [line for line in container_lines if "/Volumes" in line]
263 |
264 | success = result == 0 and len(volume_lines) > 0
265 | if success:
266 | container_line = container_lines[0]
267 | parts = re.split("\s+", container_line, maxsplit=2)
268 | self.temporary_container = parts[0]
269 |
270 | mount_line = volume_lines[0]
271 | parts = re.split("\s+", mount_line, maxsplit=2)
272 | self.temporary_volume = parts[0]
273 | self.temporary_mount = parts[2]
274 |
275 | report.output_files.append(self.temporary_path)
276 | # Write preliminary report
277 | self._write_report(report)
278 |
279 | return success
280 |
281 | def _detach_temporary_image(self, delay=10, interval=5, attempts=20) -> bool:
282 | print("\nWaiting to detach temporary image...")
283 | time.sleep(delay)
284 |
285 | i = 1
286 | while True:
287 | result = self._run_status(["hdiutil", "detach", self.temporary_volume])
288 | if result == 0:
289 | break
290 | i = i + 1
291 | if i == attempts:
292 | print("Failed to detach temporary image!")
293 | return False
294 | time.sleep(interval)
295 |
296 | # This could be automatically unmounted, we don't check for success
297 | _ = self._run_status(["hdiutil", "detach", self.temporary_container])
298 | return True
299 |
300 | def _generate_dmg(self, report: Report) -> bool:
301 | params = report.parameters
302 | output_directory = params.destination / params.image_name
303 | output_directory.mkdir(parents=True, exist_ok=True)
304 | self.output_path = output_directory / f"{params.image_name}.dmg"
305 |
306 | print("\nConverting", self.temporary_path, "->", self.output_path)
307 | sparseimage = f"{self.temporary_path}"
308 | dmg = f"{self.output_path}"
309 | result = self._run_status(
310 | ["hdiutil", "convert", sparseimage, "-format", "UDZO", "-o", dmg]
311 | )
312 |
313 | success = result == 0
314 | if success:
315 | report.output_files.append(self.output_path)
316 |
317 | return success
318 |
319 | def _compute_hashes(self, path: Path) -> HashedFile:
320 | print("\nHashing", path)
321 |
322 | total_size = os.stat(path).st_size
323 | amount = 0
324 | last_percent = 0
325 | chunk_size = 16 * 1024
326 |
327 | sha1 = hashlib.sha1()
328 | sha256 = hashlib.sha256()
329 | md5 = hashlib.md5()
330 |
331 | # The process needs to be caffeinated manually, because the hashing
332 | # function is done directly via our Python code. We start a caffeinate
333 | # instance with a very long duration (7 days) and terminate after the
334 | # process is completed.
335 |
336 | one_week = 60 * 60 * 24 * 7
337 | coffee = subprocess.Popen(["caffeinate", "-dimsu", "-t", f"{one_week}"])
338 |
339 | try:
340 | with open(path, "rb") as f:
341 | while True:
342 | chunk = f.read(chunk_size)
343 | if not chunk:
344 | print("")
345 | break
346 | sha1.update(chunk)
347 | sha256.update(chunk)
348 | md5.update(chunk)
349 |
350 | amount = amount + chunk_size
351 | percent = 100 * amount // total_size
352 | if percent > last_percent:
353 | print(f"{percent}% ", end="")
354 | last_percent = percent
355 | finally:
356 | coffee.kill()
357 |
358 | result = HashedFile(
359 | path, md5=md5.hexdigest(), sha1=sha1.hexdigest(), sha256=sha256.hexdigest()
360 | )
361 | return result
362 |
363 | def _write_report(self, report: Report) -> None:
364 | params = report.parameters
365 | output_directory = params.destination / params.image_name
366 | output_directory.mkdir(parents=True, exist_ok=True)
367 | self.output_report = output_directory / f"{params.image_name}.txt"
368 |
369 | print("\nWriting report file", self.output_report)
370 |
371 | separator = "-" * 80
372 |
373 | output_files = []
374 | if len(report.output_files):
375 | output_files = [
376 | separator,
377 | "Generated files:",
378 | ] + [f" - {file}" for file in report.output_files]
379 |
380 | hashes = []
381 | if report.result:
382 | hashes = [
383 | separator,
384 | f"Computed hashes ({report.result.path.name}):",
385 | f" - MD5: {report.result.md5}",
386 | f" - SHA1: {report.result.sha1}",
387 | f" - SHA256: {report.result.sha256}",
388 | ]
389 |
390 | with open(self.output_report, "w") as output:
391 | for line in (
392 | [
393 | "Fuji - Forensic Unattended Juicy Imaging",
394 | f"Version {VERSION} by {AUTHOR}",
395 | "Acquisition log",
396 | separator,
397 | f"Case name: {report.parameters.case}",
398 | f"Examiner: {report.parameters.examiner}",
399 | f"Notes: {report.parameters.notes}",
400 | separator,
401 | f"Start time: {report.start_time}",
402 | f"End time: {report.end_time}",
403 | f"Source: {report.parameters.source}",
404 | f"Acquisition method: {report.method.name}",
405 | separator,
406 | report.hardware_info,
407 | separator,
408 | "Volume:",
409 | "",
410 | report.path_details.disk_info,
411 | ]
412 | + output_files
413 | + hashes
414 | ):
415 | output.write(line + "\n")
416 |
417 | def _dmg_and_hash(self, report: Report) -> Report:
418 | result = self._detach_temporary_image()
419 | if not result:
420 | return report
421 |
422 | result = self._generate_dmg(report)
423 | if not result:
424 | return report
425 |
426 | # Compute all hashes and mark report as done
427 | report.result = self._compute_hashes(self.output_path)
428 | report.success = True
429 | report.end_time = datetime.now()
430 |
431 | self._write_report(report)
432 |
433 | print("\nAcquisition completed!")
434 | return report
435 |
436 | @abstractmethod
437 | def execute(self, params: Parameters) -> Report:
438 | pass
439 |
--------------------------------------------------------------------------------
/fuji.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | import os
3 | import re
4 | import string
5 | import subprocess
6 | import sys
7 | import threading
8 | from pathlib import Path
9 | from typing import Iterable, List
10 |
11 | import humanize
12 | import wx
13 | import wx.lib.agw.hyperlink as hl
14 |
15 | from acquisition.abstract import AcquisitionMethod, Parameters
16 | from acquisition.asr import AsrMethod
17 | from acquisition.rsync import RsyncMethod
18 | from acquisition.sysdiagnose import SysdiagnoseMethod
19 | from checks.name import NameCheck
20 | from checks.folders import FoldersCheck
21 | from checks.free_space import FreeSpaceCheck
22 | from checks.network import NetworkCheck
23 | from meta import AUTHOR, HOMEPAGE, VERSION
24 | from shared.utils import command_to_properties, lines_to_properties
25 |
26 | METHODS = [AsrMethod(), RsyncMethod(), SysdiagnoseMethod()]
27 | CHECKS = [NameCheck(), FoldersCheck(), FreeSpaceCheck(), NetworkCheck()]
28 | PARAMS = Parameters()
29 |
30 | INPUT_WINDOW: "InputWindow"
31 | OVERVIEW_WINDOW: "OverviewWindow"
32 | PROCESSING_WINDOW: "ProcessingWindow"
33 |
34 |
35 | class RedirectText(object):
36 | out: wx.TextCtrl
37 | max_lines = 500
38 |
39 | def __init__(self, control: wx.TextCtrl):
40 | self.out = control
41 |
42 | def write(self, value):
43 | wx.CallAfter(self.append_shrink, value)
44 |
45 | def append_shrink(self, value):
46 | self.out.AppendText(value)
47 | lines = self.out.GetNumberOfLines()
48 | if lines > self.max_lines:
49 | delta = lines - self.max_lines
50 | position = self.out.XYToPosition(0, delta - 1)
51 | self.out.Remove(0, position)
52 | self.out.ShowPosition(self.out.GetLastPosition())
53 |
54 |
55 | @dataclass
56 | class DiskSpaceInfo:
57 | identifier: str = ""
58 | size: int = 0
59 | used_space: int = 0
60 | free_space: int = 0
61 | mount_point: str = ""
62 |
63 |
64 | @dataclass
65 | class DeviceInfo:
66 | indent: int = 0
67 | type: str = ""
68 | name: str = ""
69 | size: str = ""
70 | identifier: str = ""
71 | status: str = ""
72 | disk_space: DiskSpaceInfo = None
73 |
74 |
75 | class DevicesWindow(wx.Frame):
76 | def _parse_stanza(self, stanza: str, mount_info: dict) -> Iterable[DeviceInfo]:
77 | lines = stanza.splitlines()
78 | first, second = lines[:2]
79 | status = ""
80 | if "(" in first:
81 | status = first.split("(")[1].split(")")[0]
82 | pivot_1 = second.index(":") + 1
83 | pivot_2 = second.index(" NAME")
84 | pivot_3 = second.index(" SIZE")
85 | pivot_4 = second.index(" IDENTIFIER")
86 |
87 | is_disk = True
88 | for line in lines[2:]:
89 | type = line[pivot_1 + 1 : pivot_2].strip()
90 | name = line[pivot_2:pivot_3].strip()
91 | size = line[pivot_3 + 1 : pivot_4].strip()
92 | identifier = line[pivot_4:].strip()
93 | if not identifier:
94 | continue
95 | indent = identifier[4:].count("s")
96 | if identifier == "-":
97 | indent = 1
98 | device_info = DeviceInfo(
99 | indent=indent,
100 | type=type,
101 | name=name,
102 | size=size,
103 | identifier=identifier,
104 | status=status if is_disk else "",
105 | disk_space=mount_info.get(identifier),
106 | )
107 |
108 | is_disk = False
109 | yield device_info
110 |
111 | def __init__(self, parent):
112 | super().__init__(parent, title="Fuji - Drives and partitions")
113 | self.parent = parent
114 | panel = wx.Panel(self)
115 |
116 | title = wx.StaticText(panel, label="List of drives and partitions")
117 | title_font: wx.Font = title.GetFont()
118 | title_font.SetPointSize(18)
119 | title_font.SetWeight(wx.FONTWEIGHT_BOLD)
120 | title.SetFont(title_font)
121 |
122 | devices_label = wx.StaticText(
123 | panel,
124 | label="The source can be set by double-clicking on a mounted partition",
125 | )
126 |
127 | self.list_ctrl = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
128 | self.list_ctrl.Bind(wx.EVT_LIST_ITEM_FOCUSED, self.on_item_focused)
129 | self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
130 |
131 | mount_info = {}
132 | df_lines = subprocess.check_output(["df"], universal_newlines=True).splitlines()
133 | for line in df_lines:
134 | if not line.startswith("/dev/disk"):
135 | continue
136 | identifier, size, used, free, _, _, _, _, mount_point = re.split(
137 | "\s+", line, maxsplit=8
138 | )
139 | short_identifier = identifier[5:]
140 | mount_info[short_identifier] = DiskSpaceInfo(
141 | identifier=identifier,
142 | size=int(size) * 512,
143 | used_space=int(used) * 512,
144 | free_space=int(free) * 512,
145 | mount_point=mount_point,
146 | )
147 |
148 | self.devices: List[DeviceInfo] = []
149 |
150 | diskutil_list = subprocess.check_output(
151 | ["diskutil", "list"], universal_newlines=True
152 | )
153 | stanzas = diskutil_list.strip().split("\n\n")
154 | for stanza in stanzas:
155 | self.devices.extend(self._parse_stanza(stanza, mount_info))
156 |
157 | # Add columns to the list control
158 | columns = [
159 | "Identifier",
160 | "Type",
161 | "Name",
162 | "Size",
163 | "Device status",
164 | "Mount point",
165 | "Used space",
166 | ]
167 |
168 | for index, col in enumerate(columns):
169 | self.list_ctrl.InsertColumn(index, col, width=-1)
170 |
171 | self.selected_index = -1
172 |
173 | highlight = wx.Colour()
174 | highlight.SetRGBA(0x18808080)
175 | for index, line in enumerate(self.devices):
176 | mount_point = ""
177 | size_str = line.size
178 | used_str = ""
179 | if line.type in ("APFS Volume", "APFS Snapshot"):
180 | used_str = size_str
181 | size_str = "^"
182 | disk_space = line.disk_space
183 | if disk_space:
184 | mount_point = disk_space.mount_point
185 | used_str = humanize.naturalsize(disk_space.used_space)
186 | if mount_point == "/":
187 | estimated_used = humanize.naturalsize(
188 | disk_space.size - disk_space.free_space
189 | )
190 | used_str = f"{estimated_used} (~)"
191 |
192 | index = self.list_ctrl.InsertItem(
193 | index, f"{' ' * line.indent}{line.identifier}"
194 | )
195 | self.list_ctrl.SetItem(index, 1, line.type)
196 | self.list_ctrl.SetItem(index, 2, line.name)
197 | self.list_ctrl.SetItem(index, 3, size_str)
198 | self.list_ctrl.SetItem(index, 4, line.status)
199 | self.list_ctrl.SetItem(index, 5, mount_point)
200 | self.list_ctrl.SetItem(index, 6, used_str)
201 | self.list_ctrl.SetItemData(index, index)
202 | if f"{PARAMS.source}" == mount_point:
203 | self.list_ctrl.Select(index)
204 | self.list_ctrl.Focus(index)
205 | self.selected_index = index
206 | if index % 2:
207 | self.list_ctrl.SetItemBackgroundColour(index, highlight)
208 | if not mount_point:
209 | self.list_ctrl.SetItemTextColour(index, (128, 128, 128))
210 |
211 | padding = 10
212 | width = padding * 4
213 | height = padding * 4
214 | for index in range(len(columns)):
215 | self.list_ctrl.SetColumnWidth(index, wx.LIST_AUTOSIZE)
216 | # Add a bit of padding
217 | padded_width = self.list_ctrl.GetColumnWidth(index) + padding
218 | padded_width = max(padded_width, 100)
219 | if index == 2:
220 | padded_width = min(padded_width, 180)
221 | self.list_ctrl.SetColumnWidth(index, padded_width)
222 | width = width + padded_width
223 |
224 | for index in range((self.list_ctrl.ItemCount)):
225 | rect: wx.Rect = self.list_ctrl.GetItemRect(index)
226 | height = height + rect.GetHeight()
227 |
228 | self.list_ctrl.SetMinSize(wx.Size(width, height))
229 |
230 | # Add controls to the sizer
231 | vbox = wx.BoxSizer(wx.VERTICAL)
232 | vbox.Add(title, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 20)
233 | vbox.Add(devices_label, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 10)
234 | vbox.Add((0, 10))
235 | vbox.Add(self.list_ctrl, 1, wx.EXPAND | wx.ALL, border=10)
236 | panel.SetSizerAndFit(vbox)
237 |
238 | sizer = wx.GridSizer(1)
239 | sizer.Add(panel, 1, wx.EXPAND | wx.ALL)
240 | self.SetSizerAndFit(sizer)
241 |
242 | # The list control might become quite big, thus this line sets a
243 | # reasonable minimum so the window can be reduced
244 | self.SetMinSize(wx.Size(480, 240))
245 |
246 | def _back_to_selected(self):
247 | try:
248 | self.list_ctrl.Select(self.selected_index)
249 | self.list_ctrl.Focus(self.selected_index)
250 | except:
251 | pass
252 |
253 | def on_item_focused(self, event):
254 | index = event.GetIndex()
255 | device: DeviceInfo = self.devices[index]
256 | if device.disk_space and device.disk_space.mount_point:
257 | self.selected_index = event.GetIndex()
258 | else:
259 | self._back_to_selected()
260 |
261 | def on_item_activated(self, event):
262 | index = event.GetIndex()
263 | device: DeviceInfo = self.devices[index]
264 | if device.disk_space and device.disk_space.mount_point:
265 | PARAMS.source = device.disk_space.mount_point
266 | self.parent.source_picker.SetPath(device.disk_space.mount_point)
267 | self.parent.source_picker.SetFocus()
268 |
269 | # Clean up event listeners and close
270 | self.list_ctrl.Unbind(
271 | wx.EVT_LIST_ITEM_FOCUSED, handler=self.on_item_focused
272 | )
273 | self.list_ctrl.Unbind(
274 | wx.EVT_LIST_ITEM_ACTIVATED, handler=self.on_item_activated
275 | )
276 | self.Close()
277 | else:
278 | self._back_to_selected()
279 |
280 |
281 | class InputWindow(wx.Frame):
282 | method: AcquisitionMethod
283 |
284 | def __init__(self):
285 | super().__init__(
286 | parent=None,
287 | title="Fuji - Forensic Unattended Juicy Imaging",
288 | size=(600, 400),
289 | style=wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX),
290 | )
291 | self.EnableMaximizeButton(False)
292 | panel = wx.Panel(self)
293 |
294 | # Components
295 | title = wx.StaticText(panel, label="Fuji")
296 | title_font: wx.Font = title.GetFont()
297 | title_font.SetPointSize(36)
298 | title_font.SetWeight(wx.FONTWEIGHT_EXTRABOLD)
299 | title.SetFont(title_font)
300 | desc = wx.StaticText(panel, label="Forensic Unattended Juicy Imaging")
301 | desc_font: wx.Font = desc.GetFont()
302 | desc_font.SetPointSize(18)
303 | desc_font.SetWeight(wx.FONTWEIGHT_BOLD)
304 | desc.SetFont(desc_font)
305 |
306 | byline_text = wx.StaticText(panel, label=f"Version {VERSION} by {AUTHOR}")
307 | byline_link = hl.HyperLinkCtrl(panel, label=HOMEPAGE, URL=HOMEPAGE)
308 | accent = wx.Colour(181, 78, 78)
309 | byline_link.SetColours(accent, accent, accent)
310 | byline_link.SetBold(True)
311 | byline_link.UpdateLink()
312 |
313 | case_label = wx.StaticText(panel, label="Case name:")
314 | self.case_text = wx.TextCtrl(panel, value=PARAMS.case)
315 | examiner_label = wx.StaticText(panel, label="Examiner:")
316 | self.examiner_text = wx.TextCtrl(panel, value=PARAMS.examiner)
317 | notes_label = wx.StaticText(panel, label="Notes:")
318 | self.notes_text = wx.TextCtrl(panel, value=PARAMS.notes)
319 |
320 | output_label = wx.StaticText(panel, label="Image name:")
321 | self.output_text = wx.TextCtrl(panel, value=PARAMS.image_name)
322 | self.output_text.Bind(wx.EVT_CHAR, self._validate_image_name)
323 | source_label = wx.StaticText(panel, label="Source:")
324 | self.source_picker = wx.DirPickerCtrl(panel)
325 | self.source_picker.SetInitialDirectory("/")
326 | self.source_picker.SetPath(str(PARAMS.source))
327 | # Add Devices button
328 | devices_button = wx.Button(panel, label="List of drives and partitions")
329 | devices_button.Bind(wx.EVT_BUTTON, self.on_open_devices)
330 | tmp_label = wx.StaticText(panel, label="Temp image location:")
331 | self.tmp_picker = wx.DirPickerCtrl(panel)
332 | self.tmp_picker.SetInitialDirectory("/Volumes")
333 | if os.path.isdir(PARAMS.tmp):
334 | self.tmp_picker.SetPath(str(PARAMS.tmp))
335 | destination_label = wx.StaticText(panel, label="DMG destination:")
336 | self.tmp_picker.Bind(wx.EVT_DIRPICKER_CHANGED, self._tmp_location_changed)
337 | self.destination_picker = wx.DirPickerCtrl(panel)
338 | self.destination_picker.SetInitialDirectory("/Volumes")
339 | if os.path.isdir(PARAMS.destination):
340 | self.destination_picker.SetPath(str(PARAMS.destination))
341 | method_label = wx.StaticText(panel, label="Acquisition method:")
342 | self.method_choice = wx.Choice(panel, choices=[m.name for m in METHODS])
343 | self.method_choice.SetSelection(0)
344 |
345 | # Prepare method descriptions
346 | self.description_texts = []
347 | for method in METHODS:
348 | description_label = f"{method.name}: {method.description}"
349 | description_text = wx.StaticText(panel)
350 | description_text.SetLabelMarkup(description_label)
351 | self.description_texts.append(description_text)
352 |
353 | # Sound checkbox
354 | self.sound_checkbox = wx.CheckBox(
355 | panel, label="Play loud sound when acquisition is completed"
356 | )
357 | self.sound_checkbox.SetValue(True)
358 |
359 | # Buttons
360 | continue_btn = wx.Button(panel, label="Continue")
361 | continue_btn.Bind(wx.EVT_BUTTON, self.on_continue)
362 |
363 | # Layout
364 | vbox = wx.BoxSizer(wx.VERTICAL)
365 | vbox.Add(title, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 20)
366 | vbox.Add(desc, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 5)
367 | vbox.Add((0, 10))
368 |
369 | vbox.Add(byline_text, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 0)
370 | vbox.Add(byline_link, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 5)
371 | vbox.Add((0, 20))
372 |
373 | # Create a FlexGridSizer for labels and text controls
374 | case_info = wx.FlexGridSizer(cols=2, hgap=10, vgap=10)
375 | case_info.Add(case_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
376 | case_info.Add(self.case_text, 1, wx.EXPAND)
377 | case_info.Add(examiner_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
378 | case_info.Add(self.examiner_text, 1, wx.EXPAND)
379 | case_info.Add(notes_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
380 | case_info.Add(self.notes_text, 1, wx.EXPAND)
381 | case_info.AddGrowableCol(1, 1)
382 |
383 | vbox.Add(case_info, 0, wx.EXPAND | wx.ALL, 10)
384 |
385 | output_info = wx.FlexGridSizer(cols=2, hgap=10, vgap=10)
386 | output_info.Add(output_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
387 | output_info.Add(self.output_text, 1, wx.EXPAND)
388 | output_info.Add(source_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
389 | output_info.Add(self.source_picker, 1, wx.EXPAND)
390 | output_info.Add((0, 0))
391 | output_info.Add(devices_button, 0, wx.EXPAND)
392 | output_info.Add(tmp_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
393 | output_info.Add(self.tmp_picker, 1, wx.EXPAND)
394 | output_info.Add(destination_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
395 | output_info.Add(self.destination_picker, 1, wx.EXPAND)
396 | output_info.Add(method_label, 0, wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL)
397 | output_info.Add(self.method_choice, 1, wx.EXPAND)
398 | output_info.AddGrowableCol(1, 1)
399 |
400 | vbox.Add(output_info, 0, wx.EXPAND | wx.ALL, 10)
401 |
402 | for description_text in self.description_texts:
403 | vbox.Add(description_text, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
404 |
405 | vbox.Add((0, 20))
406 | vbox.Add(self.sound_checkbox, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.BOTTOM, 10)
407 | vbox.Add(continue_btn, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.BOTTOM, 20)
408 | panel.SetSizer(vbox)
409 |
410 | sizer = wx.BoxSizer(wx.VERTICAL)
411 | sizer.Add(panel)
412 | self.SetSizerAndFit(sizer)
413 |
414 | # Bind close
415 | self.Bind(wx.EVT_CLOSE, self.on_close)
416 |
417 | def on_open_devices(self, event):
418 | devices_window = DevicesWindow(self)
419 | devices_window.Show()
420 | devices_window.Move(64, 64)
421 |
422 | def on_tmp_location_changed(self, event):
423 | temp_location = self.tmp_picker.GetPath()
424 | destination_location = self.destination_picker.GetPath()
425 | if not destination_location:
426 | self.destination_picker.SetPath(temp_location)
427 |
428 | def _validate_image_name(self, event):
429 | key = event.GetKeyCode()
430 | valid_characters = "-_." + string.ascii_letters + string.digits
431 |
432 | if chr(key) in valid_characters:
433 | event.Skip()
434 | return
435 | else:
436 | return False
437 |
438 | def _tmp_location_changed(self, event):
439 | temp_location = self.tmp_picker.GetPath()
440 | destination_location = self.destination_picker.GetPath()
441 | if not destination_location:
442 | self.destination_picker.SetPath(temp_location)
443 |
444 | def on_continue(self, event):
445 | PARAMS.case = self.case_text.Value
446 | PARAMS.examiner = self.examiner_text.Value
447 | PARAMS.notes = self.notes_text.Value
448 | PARAMS.image_name = self.output_text.Value
449 | PARAMS.source = Path(self.source_picker.GetPath().strip())
450 | PARAMS.tmp = Path(self.tmp_picker.GetPath().strip())
451 | PARAMS.destination = Path(self.destination_picker.GetPath().strip())
452 | PARAMS.sound = self.sound_checkbox.GetValue()
453 | self.method = METHODS[self.method_choice.GetSelection()]
454 |
455 | self.Hide()
456 | OVERVIEW_WINDOW.update_overview()
457 | OVERVIEW_WINDOW.Show()
458 |
459 | def on_close(self, event):
460 | app: wx.App = wx.GetApp()
461 | app.ExitMainLoop()
462 |
463 |
464 | class OverviewWindow(wx.Frame):
465 | def __init__(self):
466 | super().__init__(
467 | parent=None,
468 | title="Fuji - Overview",
469 | size=(800, 400),
470 | style=wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX),
471 | )
472 | panel = wx.Panel(self)
473 |
474 | # Components
475 | title = wx.StaticText(panel, label="Acquisition overview")
476 | title_font: wx.Font = title.GetFont()
477 | title_font.SetPointSize(18)
478 | title_font.SetWeight(wx.FONTWEIGHT_BOLD)
479 | title.SetFont(title_font)
480 |
481 | # Overview grid container of 2 columns
482 | self.overview_grid = wx.FlexGridSizer(cols=2, hgap=20, vgap=10)
483 | self.overview_grid.AddGrowableCol(1, 1)
484 |
485 | # Buttons
486 | back_btn = wx.Button(panel, label="Back")
487 | back_btn.Bind(wx.EVT_BUTTON, self.on_back)
488 | confirm_btn = wx.Button(panel, label="Confirm")
489 | confirm_btn.Bind(wx.EVT_BUTTON, self.on_confirm)
490 |
491 | # Layout
492 | vbox = wx.BoxSizer(wx.VERTICAL)
493 | vbox.Add(title, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 20)
494 | vbox.Add((0, 10))
495 | vbox.Add(self.overview_grid, 0, wx.EXPAND | wx.ALL, 10)
496 | vbox.Add((0, 20))
497 | hbox = wx.BoxSizer(wx.HORIZONTAL)
498 | hbox.Add(back_btn, 0, wx.RIGHT, 10)
499 | hbox.Add(confirm_btn, 0)
500 | vbox.Add(hbox, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.BOTTOM, 20)
501 |
502 | panel.SetSizer(vbox)
503 | self.panel = panel
504 |
505 | # Bind close
506 | self.Bind(wx.EVT_CLOSE, self.on_close)
507 |
508 | def update_overview(self):
509 | # Clear the existing grid content
510 | self.overview_grid.Clear(True)
511 |
512 | data = {
513 | "Case name": PARAMS.case,
514 | "Examiner": PARAMS.examiner,
515 | "Notes": PARAMS.notes,
516 | "Image name": PARAMS.image_name,
517 | "Source": PARAMS.source,
518 | "Temp image location": PARAMS.tmp,
519 | "DMG destination": PARAMS.destination,
520 | "Acquisition method": INPUT_WINDOW.method.name,
521 | "Play sound": "Yes" if PARAMS.sound else "No",
522 | }
523 |
524 | max_text_width = 600
525 |
526 | # Insert rows into the grid
527 | for label, value in data.items():
528 | label_text = wx.StaticText(self.panel, label=label)
529 | label_text_font = label_text.GetFont()
530 | label_text_font.SetWeight(wx.FONTWEIGHT_BOLD)
531 | label_text.SetFont(label_text_font)
532 | value_text = wx.StaticText(
533 | self.panel,
534 | label=f"{value}",
535 | size=(max_text_width, -1),
536 | )
537 | value_text.Wrap(max_text_width)
538 | self.overview_grid.Add(label_text, 0, wx.ALIGN_LEFT | wx.ALIGN_TOP)
539 | self.overview_grid.Add(value_text, 1, wx.ALIGN_LEFT | wx.ALIGN_TOP)
540 |
541 | # Perform checks
542 | for check in CHECKS:
543 | result = check.execute(PARAMS)
544 | label_text = wx.StaticText(self.panel, label=check.name)
545 | label_text_font = label_text.GetFont()
546 | label_text_font.SetWeight(wx.FONTWEIGHT_BOLD)
547 | label_text.SetFont(label_text_font)
548 | if not result.passed:
549 | label_text.SetForegroundColour((240, 20, 20))
550 | value_text = wx.StaticText(
551 | self.panel,
552 | label=result.message,
553 | size=(max_text_width, -1),
554 | )
555 | value_text.Wrap(max_text_width)
556 | self.overview_grid.Add(label_text, 0, wx.ALIGN_LEFT | wx.ALIGN_TOP)
557 | self.overview_grid.Add(value_text, 1, wx.ALIGN_LEFT | wx.ALIGN_TOP)
558 |
559 | # Update the layout
560 | self.panel.Layout()
561 | self.panel.Fit()
562 | self.Fit()
563 |
564 | def on_back(self, event):
565 | # Hide the overview window and show the input window again
566 | self.Hide()
567 | INPUT_WINDOW.Show()
568 |
569 | def on_confirm(self, event):
570 | # Start acquisition
571 | self.Hide()
572 | PROCESSING_WINDOW.activate()
573 |
574 | def on_close(self, event):
575 | self.on_back(event)
576 |
577 |
578 | class ProcessingWindow(wx.Frame):
579 | def __init__(self):
580 | super().__init__(
581 | parent=None,
582 | title="Fuji - Acquisition",
583 | size=(800, 600),
584 | )
585 | self.panel = wx.Panel(self)
586 |
587 | # Components
588 | self.title = wx.StaticText(self.panel, label="Acquisition in progress")
589 | self.title_font: wx.Font = self.title.GetFont()
590 | self.title_font.SetPointSize(18)
591 | self.title_font.SetWeight(wx.FONTWEIGHT_BOLD)
592 | self.title.SetFont(self.title_font)
593 | self.output_text = wx.TextCtrl(
594 | self.panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.VSCROLL
595 | )
596 |
597 | # Layout
598 | vbox = wx.BoxSizer(wx.VERTICAL)
599 | vbox.Add(self.title, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 20)
600 | vbox.Add((0, 10))
601 | vbox.Add(self.output_text, 1, wx.EXPAND | wx.ALL, 10)
602 |
603 | self.panel.SetSizer(vbox)
604 |
605 | # Bind close
606 | self.Bind(wx.EVT_CLOSE, self.on_close)
607 |
608 | def activate(self):
609 | self.running = True
610 |
611 | # Reset initial status
612 | self.title.SetLabel("Acquisition in progress")
613 | self.title.SetForegroundColour(wx.NullColour)
614 | self.title.SetFont(self.title_font)
615 | self.output_text.SetValue("")
616 |
617 | self.Show()
618 |
619 | # Redirect sys.stdout to the custom file-like object
620 | redir = RedirectText(self.output_text)
621 | sys.stdout = redir
622 | sys.stderr = redir
623 |
624 | # Start acquisition process in a separate thread
625 | self.acquisition_thread = threading.Thread(target=self.execute_acquisition)
626 | self.acquisition_thread.start()
627 |
628 | def execute_acquisition(self):
629 | try:
630 | method = INPUT_WINDOW.method
631 | result = method.execute(PARAMS)
632 |
633 | # Process ended
634 | wx.CallAfter(self.set_completion_status, result.success)
635 |
636 | if PARAMS.sound:
637 | self.play_sound(result.success)
638 |
639 | except Exception as e:
640 | # Acquisition failed
641 | wx.CallAfter(self.set_completion_status, False)
642 | wx.CallAfter(sys.stdout.write, f"Error: {str(e)}\n")
643 |
644 | def play_sound(self, success: bool):
645 | MAX_VOLUME = 7
646 |
647 | volume_settings = subprocess.check_output(
648 | ["osascript", "-e", "get volume settings"], universal_newlines=True
649 | )
650 | volume_properties = lines_to_properties(volume_settings.split(","))
651 | try:
652 | current_volume = int(volume_properties.get("output volume"))
653 | except:
654 | # Keep reasonable volume
655 | current_volume = 50
656 | scaled = MAX_VOLUME * (current_volume / 100.0)
657 | rounded = round(scaled, 4)
658 |
659 | # Play the sound
660 | subprocess.call(["osascript", "-e", f"set Volume {MAX_VOLUME}"])
661 | sound = "Glass" if success else "Basso"
662 | subprocess.call(["afplay", f"/System/Library/Sounds/{sound}.aiff"])
663 | subprocess.call(["osascript", "-e", f"set Volume {rounded}"])
664 |
665 | def set_completion_status(self, success):
666 | if success:
667 | self.title.SetLabel("Acquisition completed")
668 | self.title.SetForegroundColour((20, 240, 20))
669 | else:
670 | self.title.SetLabel("Acquisition failed")
671 | self.title.SetForegroundColour((240, 20, 20))
672 | self.title.SetFont(self.title_font)
673 | self.running = False
674 |
675 | def on_close(self, event):
676 | if not self.running:
677 | self.Hide()
678 | INPUT_WINDOW.Show()
679 |
680 |
681 | if __name__ == "__main__":
682 | # Try to find the serial number
683 | information = command_to_properties(
684 | ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
685 | separator="=",
686 | strip_chars='"<> ',
687 | )
688 | if "IOPlatformSerialNumber" in information:
689 | serial_number = information["IOPlatformSerialNumber"]
690 | PARAMS.image_name = f"{serial_number}_Acquisition"
691 |
692 | app = wx.App()
693 | INPUT_WINDOW = InputWindow()
694 | OVERVIEW_WINDOW = OverviewWindow()
695 | PROCESSING_WINDOW = ProcessingWindow()
696 |
697 | INPUT_WINDOW.Show()
698 | app.MainLoop()
699 | app.Destroy()
700 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # GNU GENERAL PUBLIC LICENSE
2 |
3 | Version 3, 29 June 2007
4 |
5 | Copyright (C) 2007 Free Software Foundation, Inc.
6 |