11import os
22import platform
3+ import plistlib
34import shutil
5+ import subprocess
46from pathlib import Path
57from subprocess import run
68
7- # Import macOS app builder if on macOS
8- if platform .system () == "Darwin" :
9- from build_macos_app import create_app_bundle
9+ import tomllib
1010
1111
1212def get_executable_extension () -> str :
@@ -15,15 +15,177 @@ def get_executable_extension() -> str:
1515
1616def load_toml (file_path : Path ) -> dict :
1717 """Load TOML file, handling different Python versions."""
18- try :
19- import tomllib
20- except ImportError :
21- import tomli as tomllib
22-
2318 with open (file_path , "rb" ) as f :
2419 return tomllib .load (f )
2520
2621
22+ def create_icns_from_png (png_path : Path , output_icns : Path ) -> bool :
23+ """
24+ Convert PNG to ICNS format using sips (macOS built-in tool).
25+ Returns True if successful, False otherwise.
26+ """
27+ try :
28+ if platform .system () != "Darwin" :
29+ print ("Warning: ICNS conversion is only supported on macOS" )
30+ return False
31+
32+ # create iconset directory
33+ iconset_dir = output_icns .parent / f"{ output_icns .stem } .iconset"
34+ iconset_dir .mkdir (exist_ok = True )
35+
36+ # generate different sizes required for ICNS
37+ sizes = [16 , 32 , 64 , 128 , 256 , 512 , 1024 ]
38+ for size in sizes :
39+ output_file = iconset_dir / f"icon_{ size } x{ size } .png"
40+ subprocess .run (
41+ [
42+ "sips" ,
43+ "-z" ,
44+ str (size ),
45+ str (size ),
46+ str (png_path ),
47+ "--out" ,
48+ str (output_file ),
49+ ],
50+ check = True ,
51+ capture_output = True ,
52+ )
53+
54+ # create @2x versions for retina displays (except for largest size)
55+ if size <= 512 :
56+ retina_size = size * 2
57+ output_file_2x = iconset_dir / f"icon_{ size } x{ size } @2x.png"
58+ subprocess .run (
59+ [
60+ "sips" ,
61+ "-z" ,
62+ str (retina_size ),
63+ str (retina_size ),
64+ str (png_path ),
65+ "--out" ,
66+ str (output_file_2x ),
67+ ],
68+ check = True ,
69+ capture_output = True ,
70+ )
71+
72+ # convert iconset to icns
73+ subprocess .run (
74+ ["iconutil" , "-c" , "icns" , str (iconset_dir ), "-o" , str (output_icns )],
75+ check = True ,
76+ capture_output = True ,
77+ )
78+
79+ # clean up iconset directory
80+ shutil .rmtree (iconset_dir )
81+ print (f"Created ICNS icon: { output_icns } " )
82+ return True
83+
84+ except Exception as e :
85+ print (f"Failed to create ICNS: { e } " )
86+ return False
87+
88+
89+ def create_info_plist (app_name : str , version : str , bundle_identifier : str ) -> dict :
90+ """Create the Info.plist dictionary for the macOS app."""
91+ return {
92+ "CFBundleDevelopmentRegion" : "en" ,
93+ "CFBundleDisplayName" : app_name ,
94+ "CFBundleExecutable" : app_name ,
95+ "CFBundleIconFile" : "AppIcon.icns" ,
96+ "CFBundleIdentifier" : bundle_identifier ,
97+ "CFBundleInfoDictionaryVersion" : "6.0" ,
98+ "CFBundleName" : app_name ,
99+ "CFBundlePackageType" : "APPL" ,
100+ "CFBundleShortVersionString" : version ,
101+ "CFBundleVersion" : version ,
102+ "LSMinimumSystemVersion" : "10.13" ,
103+ "NSHighResolutionCapable" : True ,
104+ "NSPrincipalClass" : "NSApplication" ,
105+ "NSRequiresAquaSystemAppearance" : False ,
106+ }
107+
108+
109+ def create_app_bundle (
110+ pyinstaller_output_dir : Path ,
111+ app_name : str ,
112+ version : str ,
113+ bundle_identifier : str ,
114+ icon_path : Path | None = None ,
115+ ) -> Path :
116+ """
117+ Create a macOS .app bundle from PyInstaller output.
118+
119+ Args:
120+ pyinstaller_output_dir: Path to PyInstaller's dist output directory
121+ app_name: Name of the application
122+ version: Version string
123+ bundle_identifier: Bundle identifier (e.g., com.example.app)
124+ icon_path: Path to PNG icon file (will be converted to ICNS)
125+
126+ Returns:
127+ Path to the created .app bundle
128+ """
129+ # define .app structure paths
130+ app_bundle = pyinstaller_output_dir .parent / f"{ app_name } .app"
131+ contents_dir = app_bundle / "Contents"
132+ macos_dir = contents_dir / "MacOS"
133+ resources_dir = contents_dir / "Resources"
134+
135+ # clean up if it already exists
136+ if app_bundle .exists ():
137+ shutil .rmtree (app_bundle )
138+
139+ # create directory structure
140+ macos_dir .mkdir (parents = True , exist_ok = True )
141+ resources_dir .mkdir (parents = True , exist_ok = True )
142+
143+ print (f"Creating app bundle: { app_bundle } " )
144+
145+ # move PyInstaller contents to MacOS directory
146+ # PyInstaller creates a folder with the app name containing the executable and resources
147+ pyinstaller_app_dir = pyinstaller_output_dir / app_name
148+ if pyinstaller_app_dir .exists ():
149+ # move everything from the PyInstaller output to MacOS
150+ for item in pyinstaller_app_dir .iterdir ():
151+ dest = macos_dir / item .name
152+ if item .is_dir ():
153+ shutil .copytree (item , dest )
154+ else :
155+ shutil .copy2 (item , dest )
156+ print (f"Copied PyInstaller output to MacOS directory" )
157+ else :
158+ raise FileNotFoundError (f"PyInstaller output not found: { pyinstaller_app_dir } " )
159+
160+ # handle icon
161+ icns_path = resources_dir / "AppIcon.icns"
162+ if icon_path and icon_path .exists ():
163+ if icon_path .suffix .lower () == ".png" :
164+ create_icns_from_png (icon_path , icns_path )
165+ elif icon_path .suffix .lower () == ".icns" :
166+ shutil .copy2 (icon_path , icns_path )
167+ else :
168+ print (f"Warning: Unsupported icon format: { icon_path .suffix } " )
169+ else :
170+ print ("Warning: No icon provided" )
171+
172+ # create Info.plist
173+ plist_data = create_info_plist (app_name , version , bundle_identifier )
174+ plist_path = contents_dir / "Info.plist"
175+ with open (plist_path , "wb" ) as f :
176+ plistlib .dump (plist_data , f )
177+ print (f"Created Info.plist" )
178+
179+ # make the executable actually executable
180+ executable = macos_dir / app_name
181+ if executable .exists ():
182+ os .chmod (executable , 0o755 )
183+ print (f"Set executable permissions on { app_name } " )
184+
185+ print (f"Successfully created app bundle: { app_bundle } " )
186+ return app_bundle
187+
188+
27189# def get_site_packages() -> Path:
28190# output = run(
29191# ("uv", "pip", "show", "PySide6"),
@@ -89,13 +251,13 @@ def build_app():
89251 "bundle" ,
90252 "--windowed" ,
91253 ]
92-
93- # Only add icon on Windows/Linux; macOS uses .icns which is added during .app bundle creation
254+
255+ # only add icon on Windows/Linux; macOS uses .icns which is added during .app bundle creation
94256 if platform .system () != "Darwin" :
95257 build_args .append (f"--icon={ str (icon_path )} " )
96-
258+
97259 build_args .extend (["-y" , str (desktop_script )])
98-
260+
99261 build_job_onedir = run (build_args )
100262
101263 # cleanse included runtime folder of unneeded files
@@ -126,30 +288,30 @@ def build_app():
126288 # else:
127289 # success_msgs.append("Onefile build did not complete successfully")
128290
129- # Check onedir (bundle) build
291+ # check onedir (bundle) build
130292 onedir_path = Path ("bundled_mode" ) / "Mp4Forge" / f"Mp4Forge{ exe_str } "
131293 build_succeeded = onedir_path .is_file () and str (build_job_onedir .returncode ) == "0"
132-
294+
133295 if build_succeeded :
134296 success_msgs .append (f"Bundle build success! Path: { Path .cwd () / onedir_path } " )
135297 else :
136298 success_msgs .append ("Bundle build did not complete successfully" )
137299
138- # Store absolute path before changing directory
300+ # store absolute path before changing directory
139301 pyinstaller_output = pyinstaller_folder / "bundled_mode"
140302
141303 # change directory back to original directory
142304 os .chdir (desktop_script .parent )
143305
144- # On macOS, create a proper .app bundle
306+ # on macOS, create a proper .app bundle
145307 if platform .system () == "Darwin" and build_succeeded :
146308 try :
147- # Get version from pyproject.toml
309+ # get version from pyproject.toml
148310 pyproject_path = project_root / "pyproject.toml"
149311 pyproject = load_toml (pyproject_path )
150312 version = pyproject ["project" ]["version" ]
151313
152- # Create .app bundle (pyinstaller_output already defined above)
314+ # create .app bundle (pyinstaller_output already defined above)
153315 icon_png = project_root / "runtime" / "images" / "mp4.png"
154316
155317 app_bundle = create_app_bundle (
0 commit comments