def ensure_xcode_dependencies(): """ Ensure that a valid version of Xcode and command line tools are installed. """ msg_intro = 'Running your app in the simulator or on a developer device ' \ 'requires Xcode to be installed on your machine (version ' \ '%s or higher).' % MIN_XCODE_VERSION install_instruct = 'We couldn\'t find an installation, would you like ' \ 'to open the Mac App Store and install it now? [Y/n]: ' upgrade_instruct = 'You must upgrade your version of Xcode. Would you ' \ 'like to open the Mac App Store and install it now?' \ ' [Y/n]: ' if not command_line_tools_installed(): install_tools = yn('We need to install the Xcode command line tools. ' \ 'Continue? [Y/n]: ') if install_tools: subprocess.call(['xcode-select', '--install']) wait_for_tools_install() else: sys.exit(1) # Check if xcode build is installed if not xcode_is_installed(): if yn('%s %s' % (msg_intro, install_instruct)): open_mac_app_store() sys.exit(1) if not xcode_version_valid(): if yn('%s %s' % (msg_intro, upgrade_instruct)): open_mac_app_store() sys.exit(1)
def ensure_base(version): # Make sure that the correct siphon-base package is installed for this # base version; if not, install it. Returns the location of the # base project. auth = Auth() conf = Config() siphon = Siphon(auth.auth_token) build = Build(conf.app_id, version) pkg_url = siphon.base_package_url(version) pkg_dest = Cache.base_package_path(version) puts(colored.yellow('Checking for required packages...')) content_length = siphon.content_length_for_version(version) if Cache.base_package_installed(version): cached_content_length = Cache.get_length_for_base_version(version) if content_length != cached_content_length: update_msg = 'Siphon needs to update some files in order to run ' \ 'your app (%s). Proceed? ' \ '[Y/n]: ' % format_size(content_length) update = yn(update_msg) if not update: sys.exit(1) msg = 'Updating Siphon files for base version %s...' % version try: download_file(pkg_url, pkg_dest, msg) Cache.set_length_for_base_version(version, content_length) except KeyboardInterrupt: sys.exit(1) # Get rid of the old build dirs (they are now invalid) build.clean_builds() else: download_msg = 'Siphon needs to download some files in order to run ' \ 'your app (%s). This will not be ' \ 'required again unless an update is needed, or a ' \ 'different base version is specified in ' \ 'the Siphonfile of one of your apps. Proceed? ' \ '[Y/n]: ' % format_size(content_length) download = yn(download_msg) if not download: sys.exit(1) msg = 'Downloading compatibility files for base version ' \ '%s...' % version try: download_file(pkg_url, pkg_dest, msg) except KeyboardInterrupt: sys.exit(1) Cache.set_length_for_base_version(version, content_length) build.clean_builds() build.ensure_build_dir()
def ensure_publish_dir(): # Make sure that a publish directory exists. If it does not, ask the # user if they would like to create one. After running the function # we should exit - we don't want to proceed with a publish if the # icons are dummy ones dir_exists = os.path.exists(PUBLISH_DIR) if not dir_exists: proceed = yn('A \'publish\' directory does not exist for this app. ' \ 'This folder will contain the icons for your app and is required ' \ 'for publishing. Would you like to create one now? [Y/n]: ') if not proceed: sys.exit(1) # Copy the icons over from our resources dir for platform in ALLOWED_PLATFORMS: icon_dir = os.path.join(PUBLISH_DIR, platform, 'icons') ensure_dir_exists(icon_dir) icon = os.path.join(CLI_RESOURCES, 'icons', platform, 'index.png') icon_dest = os.path.join(icon_dir, 'index.png') copyfile(icon, icon_dest) puts(colored.yellow('A \'publish\' directory has been created. ' \ 'This contains placeholder icons for your app. Please make ' \ 'sure you replace these with your own icons.')) sys.exit(0)
def prompt_beta_share_for_all(shared_with): msg = 'You are about to share the latest changes for this app. ' \ 'The following beta testers will be notified that there is an ' \ 'update available:\n\n' for obj in shared_with: msg += ' --> %s (%s)\n' % (obj['username'], obj['email']) msg += '\nContinue? [Y/n]: ' return yn(msg)
def ensure_develop_dir(self): ensure_dir_exists(self.directory) # Make sure that the entry script is copied over scripts_dir = os.path.join(PACKAGER_RESOURCES, self.base_version, 'scripts') scripts = os.listdir(scripts_dir) for s in scripts: src = os.path.join(scripts_dir, s) dest = os.path.join(self.directory, s) shutil.copyfile(src, dest) # We make sure that the required node modules are installed modules_installed = True dependencies = packager_dependencies(self.base_version) required_modules = list(dependencies.keys()) try: modules_dir = os.path.join(self.directory, 'node_modules') node_modules = os.listdir(modules_dir) if not set(node_modules) >= set(required_modules): modules_installed = False except OSError: modules_installed = False if not modules_installed: proceed = yn('We need to download some dependencies. ' \ 'This may take a few minutes. Proceed? [Y/n] ') if not proceed: sys.exit(1) with cd(self.directory): try: npm = npm_cmd(self.base_version) # We want to install react-native first so peer # dependencies are met required_modules.insert(0, required_modules.pop( \ required_modules.index('react-native'))) for m in required_modules: version = dependencies[m] is_repo = 'http' in version print('Downloading %s...' % m) if is_repo: p = background_process('%s install %s' % \ (npm, version), spinner=True) else: p = background_process('%s install ' \ '%s@%s' % (npm, m, version), spinner=True) out, err = p.communicate() if p.returncode != 0: print(err.decode()) sys.exit(1) # Run the associated post-install script if it exists if os.path.isfile('post-install.sh'): print('Running postinstall script...') background_process('sh post-install.sh', spinner=True) except KeyboardInterrupt: sys.exit(1)
def prompt_team_share(shared_with): msg = 'This will share a special team copy of your app with the person ' \ 'who owns the email address that you specified. They will be sent ' \ 'an email with an explanation and a link to accept the invite.' msg += '\n\nPlease read this page before you continue: %s' % \ TEAM_SHARE_DOCS_URL if shared_with: msg += '\n\nThe follow users are currently active team members for ' \ 'this app: \n\n' for obj in shared_with: msg += ' --> %s (%s)' % (obj['username'], obj['email']) msg += '\n\nContinue? [Y/n]: ' return yn(msg)
def prompt_beta_share_for_specific_email(shared_with): msg = 'This will share a read-only copy of your app with the person who ' \ 'owns the email address that you specified.\n\nThey will be sent an ' \ 'email explaining how to download the Siphon Sandbox and run your ' \ 'app. If they do not yet have a Siphon account, they will be ' \ 'prompted to create one.' if shared_with: msg += '\n\nIn addition, the following active beta testers will ' \ 'receive the latest app changes:\n\n' for obj in shared_with: msg += ' --> %s (%s)' % (obj['username'], obj['email']) msg += '\n\nContinue? [Y/n]: ' return yn(msg)
def clear_builds(): """ Wipe any build directories for the app. """ proceed = yn('This operation will remove any builds for this app. ' \ 'The app will need to be rebuilt to run it again. ' \ 'Proceed? [Y/n]: ') if proceed: conf = Config() build = Build(conf.app_id, '') build.clean_builds() else: return
def ensure_wwdr_cert(): valid_cert_installed = valid_wwdr_cert_installed() if not valid_cert_installed: puts(colored.red('No valid WWDR certificate detected.')) proceed = yn('We need to download and install a new one ' \ 'in your keychain ' \ '(See https://developer.apple.com/support/certificates/expiration/ ' \ 'for more details).\n' 'Proceed? [Y/n]: ') if proceed: try: print('Downloading and installing new certificate...') add_wwdr_cert() print('Certificate installed successfully') return True except KeyboardInterrupt: return False else: return True
def ensure_node(version): if not node_cmd(version): # Neither a valid global or siphon installation was found, so we # must download the correct binary. version_exists = NODE_BINARIES.get(version) if not version_exists: puts(colored.red('Base version not supported. Please set ' \ 'the "base_version" value in your app\'s Siphonfile to one ' \ 'of the following: ')) for k in reversed(sorted(list(NODE_BINARIES.keys()))): print(k) sys.exit(1) if get_platform_name() != PLATFORM_DARWIN: raise SiphonClientException('Node not supported on platform.') url = NODE_BINARIES[version]['darwin-64']['url'] node_size = format_size(get_download_size(url)) proceed = yn('We need to download Node.js & npm (we won\'t override ' \ 'any current installations you may have). ' \ 'These are required to run the packager and download ' \ 'any dependencies we need. Download? (%s) ' \ '[Y/n] ' % node_size) if not proceed: sys.exit(1) version_dest = os.path.join(NODE_DESTINATION, version) ensure_dir_exists(version_dest) dest = os.path.join(version_dest, os.path.basename(url)) download_file(url, dest, 'Downloading Node.js & npm...') print('Installing node...') tf = tarfile.open(dest, 'r:gz') content_dir = os.path.join(version_dest, NODE_BINARIES[version]['darwin-64']['content']) tf.extractall(version_dest) move_contents_to_parent(content_dir) print('Installation successful.')
def archive(sim, arch_dir, project_dir): """ Handle archiving in a user-friendly way """ proceed = yn('A build is required for this app. ' \ 'This step is needed if you haven\'t run this ' \ 'app on this particular simulator before, ' \ 'you have changed the name or base version, or ' \ 'if an update has recently been performed. ' \ 'This may take a few minutes. Proceed? ' \ '[Y/n]: ') if proceed: try: app = sim.archive(project_dir, arch_dir) return app except SiphonSimulatorException: cleanup_dir(arch_dir) sys.exit(1) except KeyboardInterrupt: puts(colored.red('\nArchiving interrupted')) cleanup_dir(arch_dir) sys.exit(1) else: sys.exit(1)
def publish(platform): # Ensure that a publish directory exists ensure_publish_dir() ensure_platform_keys() # Load the app config and wrapper auth = Auth() conf = Config() siphon = Siphon(auth.auth_token) mixpanel_props = { 'upgrade_required': False, 'platform': platform } # We first check that the user has a valid subscription, because we're # going to do an implicit push before submitting, and it would otherwise # be confusing. puts(colored.yellow('Checking your account...')) if not siphon.user_can_publish(): mixpanel_props['upgrade_required'] = True mixpanel_event(MIXPANEL_EVENT_PUBLISH, properties=mixpanel_props) prompt_for_upgrade() sys.exit(1) else: puts(colored.green('Your account is ready for publishing.')) mixpanel_event(MIXPANEL_EVENT_PUBLISH, properties=mixpanel_props) # It looks like the user can publish, so we do an implicit push # and validate the app. app_valid = validate_app([platform]) if not app_valid: # The last push before publishing should be a successful one sys.exit(1) # Check if this submission requires a hard update, and if so then # prompt the user before going ahead with the submit so they understand. hard_update = siphon.app_requires_hard_update(conf.app_id, platform) if hard_update: if not prompt_for_hard_update(platform): return else: puts(colored.green('This update can be published without changing ' \ 'the app binary. It will be available straight away.')) # Prompt for platform info and do the actual submission user, password = None, None if hard_update: user, password = prompt_for_platform_info(platform) if hard_update: if not yn('Your app is about to be processed and submitted to the ' \ 'store. Are you sure you want to continue? [Y/n]: '): sys.exit(0) else: if not yn('Your app update is about to be submitted and the users ' \ 'of your app will receive it as an over-the-air update. Are you ' \ 'sure you want to continue? [Y/n]: '): sys.exit(0) puts(colored.yellow('Submitting...')) siphon.submit(conf.app_id, platform, username=user, password=password) puts(colored.green('\nThanks! Your app is in the queue. We will send ' \ 'status updates to your registered email address.')) print('Please note that after this submission has been released, you ' \ 'can push instant updates to your users by running the ' \ '"siphon publish" command again.')
def play(device, dev_mode=False, platform='ios'): """ Takes a device object and runs an app """ conf = Config() auth = Auth() siphon = Siphon(auth.auth_token) # Mixpanel tracking current_app_data = siphon.get_app(conf.app_id) mixpanel_props = { 'app_id': conf.app_id, 'app_name': current_app_data['name'], } mixpanel_event(MIXPANEL_EVENT_PLAY, properties=mixpanel_props) # The app needs to be built (archived) if: # # 1. An archive for this base version doesn't exist # 2. An archive exists and the display name has changed # 3. An archive exists and the facebook app id has changed # 4. An archive exists and the provisioning profile used to create it # is not installed or is not compatible with the connected device. # (device.profile_synchronised() is False.) # # After each build, we cache the build info and use it for future # reference. If build info is not stored for this app and base version, # we build the app anyway and cache the build info. # Push the app and fetch the updated settings if successful push(track_event=False) updated_app_data = siphon.get_app(conf.app_id) version = updated_app_data['base_version'] # Create a Build instance for the app build = Build(conf.app_id, version, dev_mode) # Make sure the correct base version is installed and the build # directory exists ensure_base(version) build.ensure_build_dir() # The new display name defaults to the app name if it is not set display_name = updated_app_data.get('display_name') if not display_name: display_name = updated_app_data.get('name') facebook_app_id = updated_app_data.get('facebook_app_id') bundle_id = 'com.getsiphon.%s' % conf.app_id # Initialize a BaseConfig instance that will be used to configure # the base project we're archiving. base_conf = BaseConfig(display_name, bundle_id, facebook_app_id=facebook_app_id) # Get the cached build info for the previous build cache = Cache() build_info_updated = cache.build_info_updated(build.build_id, display_name, facebook_app_id) # If the build info has been updated, we clear the old archives if build_info_updated: build.clean_archives() # If the provisioning profile needs updating, then we clear the archive # of the device build only. (The profile doesn't affect # other builds for the simulator). # Is the installed profile compatible with the connected device? profile_synchronised = device.profile_synchronised() if not profile_synchronised: build.clean_archive(device.formatted_name) # Sort out a new provisioning profile that includes the device device.update_requirements() # Was previous build performed with the installed profile? If we're # here then a compatible profile is installed. build_profile_ok = device.build_profile_ok(build.build_id) if not build_profile_ok: build.clean_archive(device.formatted_name) # If an archive doesn't exist at this point (perhaps it never existed, # or was deleted when invalidated above) we need to rebuild. if not build.archived(device.formatted_name): archive = True else: archive = False arch_dir = build.archive_dir_path(device.formatted_name) # Archiving takes a while, so prompt the user if archive: proceed = yn('A build is required for this app. ' \ 'This step is needed if you haven\'t run this ' \ 'app on the device before, ' \ 'you have changed the name or base version, or ' \ 'if an update has recently been performed. ' \ 'This may take a few minutes. Proceed? ' \ '[Y/n]: ') if proceed: try: provisioning_profile = cache.get_provisioning_profile_id() base = BaseProject(build.project_dir(platform)) with base.configure(base_conf): app = device.archive( base.directory, arch_dir, conf.app_id, auth.auth_token, provisioning_profile, dev_mode ) # Update the cached build info cache.set_build_info(build.build_id, display_name, facebook_app_id) # Record the provisioning profile used to archive this app cache.set_build_profile(build.build_id, provisioning_profile) except SiphonDeviceException: cleanup_dir(arch_dir) sys.exit(1) except KeyboardInterrupt: puts(colored.red('\nArchiving interrupted')) cleanup_dir(arch_dir) sys.exit(1) else: sys.exit(1) else: app = device.app_path(arch_dir) device.run(app)
def publish(platform): # Ensure that a publish directory exists ensure_publish_dir() ensure_platform_keys() # Load the app config and wrapper auth = Auth() conf = Config() siphon = Siphon(auth.auth_token) mixpanel_props = {'upgrade_required': False, 'platform': platform} # We first check that the user has a valid subscription, because we're # going to do an implicit push before submitting, and it would otherwise # be confusing. puts(colored.yellow('Checking your account...')) if not siphon.user_can_publish(): mixpanel_props['upgrade_required'] = True mixpanel_event(MIXPANEL_EVENT_PUBLISH, properties=mixpanel_props) prompt_for_upgrade() sys.exit(1) else: puts(colored.green('Your account is ready for publishing.')) mixpanel_event(MIXPANEL_EVENT_PUBLISH, properties=mixpanel_props) # It looks like the user can publish, so we do an implicit push # and validate the app. app_valid = validate_app([platform]) if not app_valid: # The last push before publishing should be a successful one sys.exit(1) # Check if this submission requires a hard update, and if so then # prompt the user before going ahead with the submit so they understand. hard_update = siphon.app_requires_hard_update(conf.app_id, platform) if hard_update: if not prompt_for_hard_update(platform): return else: puts(colored.green('This update can be published without changing ' \ 'the app binary. It will be available straight away.')) # Prompt for platform info and do the actual submission user, password = None, None if hard_update: user, password = prompt_for_platform_info(platform) if hard_update: if not yn('Your app is about to be processed and submitted to the ' \ 'store. Are you sure you want to continue? [Y/n]: '): sys.exit(0) else: if not yn('Your app update is about to be submitted and the users ' \ 'of your app will receive it as an over-the-air update. Are you ' \ 'sure you want to continue? [Y/n]: '): sys.exit(0) puts(colored.yellow('Submitting...')) siphon.submit(conf.app_id, platform, username=user, password=password) puts(colored.green('\nThanks! Your app is in the queue. We will send ' \ 'status updates to your registered email address.')) print('Please note that after this submission has been released, you ' \ 'can push instant updates to your users by running the ' \ '"siphon publish" command again.')
def play(device, dev_mode=False, platform='ios'): """ Takes a device object and runs an app """ conf = Config() auth = Auth() siphon = Siphon(auth.auth_token) # Mixpanel tracking current_app_data = siphon.get_app(conf.app_id) mixpanel_props = { 'app_id': conf.app_id, 'app_name': current_app_data['name'], } mixpanel_event(MIXPANEL_EVENT_PLAY, properties=mixpanel_props) # The app needs to be built (archived) if: # # 1. An archive for this base version doesn't exist # 2. An archive exists and the display name has changed # 3. An archive exists and the facebook app id has changed # 4. An archive exists and the provisioning profile used to create it # is not installed or is not compatible with the connected device. # (device.profile_synchronised() is False.) # # After each build, we cache the build info and use it for future # reference. If build info is not stored for this app and base version, # we build the app anyway and cache the build info. # Push the app and fetch the updated settings if successful push(track_event=False) updated_app_data = siphon.get_app(conf.app_id) version = updated_app_data['base_version'] # Create a Build instance for the app build = Build(conf.app_id, version, dev_mode) # Make sure the correct base version is installed and the build # directory exists ensure_base(version) build.ensure_build_dir() # The new display name defaults to the app name if it is not set display_name = updated_app_data.get('display_name') if not display_name: display_name = updated_app_data.get('name') facebook_app_id = updated_app_data.get('facebook_app_id') bundle_id = 'com.getsiphon.%s' % conf.app_id # Initialize a BaseConfig instance that will be used to configure # the base project we're archiving. base_conf = BaseConfig(display_name, bundle_id, facebook_app_id=facebook_app_id) # Get the cached build info for the previous build cache = Cache() build_info_updated = cache.build_info_updated(build.build_id, display_name, facebook_app_id) # If the build info has been updated, we clear the old archives if build_info_updated: build.clean_archives() # If the provisioning profile needs updating, then we clear the archive # of the device build only. (The profile doesn't affect # other builds for the simulator). # Is the installed profile compatible with the connected device? profile_synchronised = device.profile_synchronised() if not profile_synchronised: build.clean_archive(device.formatted_name) # Sort out a new provisioning profile that includes the device device.update_requirements() # Was previous build performed with the installed profile? If we're # here then a compatible profile is installed. build_profile_ok = device.build_profile_ok(build.build_id) if not build_profile_ok: build.clean_archive(device.formatted_name) # If an archive doesn't exist at this point (perhaps it never existed, # or was deleted when invalidated above) we need to rebuild. if not build.archived(device.formatted_name): archive = True else: archive = False arch_dir = build.archive_dir_path(device.formatted_name) # Archiving takes a while, so prompt the user if archive: proceed = yn('A build is required for this app. ' \ 'This step is needed if you haven\'t run this ' \ 'app on the device before, ' \ 'you have changed the name or base version, or ' \ 'if an update has recently been performed. ' \ 'This may take a few minutes. Proceed? ' \ '[Y/n]: ') if proceed: try: provisioning_profile = cache.get_provisioning_profile_id() base = BaseProject(build.project_dir(platform)) with base.configure(base_conf): app = device.archive(base.directory, arch_dir, conf.app_id, auth.auth_token, provisioning_profile, dev_mode) # Update the cached build info cache.set_build_info(build.build_id, display_name, facebook_app_id) # Record the provisioning profile used to archive this app cache.set_build_profile(build.build_id, provisioning_profile) except SiphonDeviceException: cleanup_dir(arch_dir) sys.exit(1) except KeyboardInterrupt: puts(colored.red('\nArchiving interrupted')) cleanup_dir(arch_dir) sys.exit(1) else: sys.exit(1) else: app = device.app_path(arch_dir) device.run(app)