├── tests ├── __init__.py ├── fake │ ├── __init__.py │ ├── lshandler_properties.py │ └── filesystem │ │ └── __init__.py ├── utils │ ├── __init__.py │ ├── settings.py │ ├── classes.py │ └── factories.py ├── assets │ ├── ex_binary.plist │ ├── ex_simple_binary.plist │ └── ex_xml.plist ├── fake_filesystem_tests.py ├── tests.py └── functional_tests.py ├── CNAME ├── payload ├── msda └── msda.py ├── _config.yml ├── requirements ├── local.txt └── ci.txt ├── .vscode └── settings.json ├── .gitignore ├── msda.code-workspace ├── .github └── workflows │ ├── test.yml │ └── build_and_release.yml ├── LICENSE ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | msda.dgrdev.com -------------------------------------------------------------------------------- /payload/msda: -------------------------------------------------------------------------------- 1 | ./msda.py -------------------------------------------------------------------------------- /tests/fake/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /requirements/local.txt: -------------------------------------------------------------------------------- 1 | -r ci.txt 2 | coverage==5.0.1 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "env/bin/python2.7" 3 | } -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | factory-boy==2.12.0 2 | Faker==2.0.3 3 | mock==3.0.5 4 | -------------------------------------------------------------------------------- /tests/assets/ex_binary.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/targendaz2/Mac-Set-Default-Apps/HEAD/tests/assets/ex_binary.plist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | *.pkg 4 | *.pyc 5 | *.coverage 6 | payload/*c 7 | /env/ 8 | /tmp/ 9 | /builds/ 10 | -------------------------------------------------------------------------------- /tests/assets/ex_simple_binary.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/targendaz2/Mac-Set-Default-Apps/HEAD/tests/assets/ex_simple_binary.plist -------------------------------------------------------------------------------- /msda.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "python.pythonPath": "env/bin/python2.7" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/utils/settings.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | 3 | THIS_FILE = os.path.dirname(sys.argv[0]) 4 | 5 | SIMPLE_BINARY_PLIST = 'ex_simple_binary.plist' 6 | BINARY_PLIST = 'ex_binary.plist' 7 | XML_PLIST = 'ex_xml.plist' 8 | EMPTY_LS_PLIST = { 'LSHandlers': [] } 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | test: 10 | runs-on: macOS-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Install testing dependencies 14 | run: pip3 install -r requirements/ci.txt 15 | - name: Run fake filesystem tests 16 | run: ./tests/fake_filesystem_tests.py 17 | - name: Run unit tests 18 | run: ./tests/tests.py 19 | - name: Run functional tests 20 | run: ./tests/functional_tests.py 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David G Rosenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/utils/classes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | import tempfile 5 | from unittest import TestCase 6 | 7 | from .factories import * 8 | from .settings import * 9 | 10 | module_path = os.path.abspath(os.path.join(THIS_FILE, '../payload')) 11 | if module_path not in sys.path: 12 | sys.path.append(module_path) 13 | 14 | import msda 15 | 16 | 17 | # Abstract Classes 18 | class LaunchServicesTestCase(TestCase): 19 | 20 | def setUp(self): 21 | self.tmp = tempfile.mkdtemp(prefix=msda.TMP_PREFIX) 22 | 23 | def tearDown(self): 24 | shutil.rmtree(self.tmp) 25 | 26 | def seed_plist(self, plist_name, location=None, target_name=None): 27 | if location == None: 28 | location = self.tmp 29 | 30 | if target_name == None: 31 | target_name = plist_name 32 | 33 | src = os.path.join(THIS_FILE, 'assets', plist_name) 34 | dest = os.path.join(location, target_name) 35 | 36 | parent_path = os.path.dirname(dest) 37 | if not os.path.exists(parent_path): 38 | os.makedirs(parent_path) 39 | 40 | shutil.copy(src, dest) 41 | return dest 42 | -------------------------------------------------------------------------------- /tests/fake/lshandler_properties.py: -------------------------------------------------------------------------------- 1 | from random import choice, random 2 | 3 | from faker import Faker 4 | from faker.providers import file, internet, lorem 5 | 6 | fake = Faker() 7 | fake.add_provider(file) 8 | fake.add_provider(internet) 9 | fake.add_provider(lorem) 10 | 11 | 12 | def fake_app_id(): 13 | app_id = '{}.{}.{}'.format( 14 | fake.tld(), 15 | fake.domain_word(), 16 | fake.word(), 17 | ) 18 | if random() > 0.1: 19 | return app_id # 90% chance: com.company.app 20 | return app_id + '.' + fake.word() # 10% chance: com.company.app.pro 21 | 22 | def fake_extension(): 23 | return str(fake.file_extension()) 24 | 25 | def fake_protocol(): 26 | protocols = [ 27 | 'http', 28 | 'https', 29 | 'xml', 30 | 'xhtml', 31 | 'ftp', 32 | ] 33 | return choice(protocols) 34 | 35 | def fake_uti(): 36 | if random() > 0.25: 37 | return 'public.{}'.format( # 75% chance: public.html 38 | fake.file_extension() 39 | ) 40 | uti = '{}.{}.{}'.format( 41 | fake.tld(), 42 | fake.domain_word(), 43 | fake.file_extension(), 44 | ) 45 | if random() > 0.8: 46 | return uti # 20% chance: com.company.file 47 | return '{}.{}.{}.{}'.format( # 5% chance: com.company.app.file 48 | fake.tld(), 49 | fake.domain_word(), 50 | fake.word(), 51 | fake.file_extension(), 52 | ) 53 | 54 | def fake_role(all=False): 55 | roles = ['editor', 'viewer'] 56 | if all: 57 | roles.append('all') 58 | return choice(roles) 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | ### Added 10 | * Created module to mock the macOS file system for local tests 11 | 12 | ### Changed 13 | * CI tests are run against an actual macOS file system 14 | 15 | ## [1.3.1] - 2021-04-24 16 | ### Fixed 17 | * MSDA would try to run as multiple users if run during macOS Setup Assistant 18 | 19 | ## [1.3.0] - 2019-12-02 20 | ### Changed 21 | * A restart is no longer needed to apply changes 22 | * Updated README to remove instructions to restart after using `set` command 23 | 24 | ## [1.2.0] - 2019-11-28 25 | ### Added 26 | * Ability to set default apps by file extension via the `-e` switch 27 | * Ability to read already set default apps via file extension 28 | 29 | ### Changed 30 | * Documented `-e` switch in README 31 | 32 | ## [1.1.3] - 2019-11-26 33 | ### Fixed 34 | * The wrong directory was targeted instead of the current user's home 35 | 36 | ## [1.1.2] - 2019-11-26 37 | ### Fixed 38 | * Using the `-feu` switch would ignore valid user homes 39 | 40 | ## [1.1.1] - 2019-11-26 41 | ### Fixed 42 | * Using the `-feu` switch would attempt to fill invalid user homes 43 | 44 | ## [1.1.0] - 2019-11-26 45 | ### Added 46 | * Added a change log 47 | * Ability to modify the default apps of all existing users via the `-feu` switch 48 | 49 | ### Changed 50 | * Documented `-feu` switch in README 51 | * Improved clarity of README 52 | 53 | ## [1.0.0] - 2019-11-09 54 | ### Added 55 | * Initial release 56 | -------------------------------------------------------------------------------- /tests/utils/factories.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import factory 5 | 6 | from fake.lshandler_properties import * 7 | from .settings import * 8 | 9 | module_path = os.path.abspath(os.path.join(__file__, '../../../payload')) 10 | if module_path not in sys.path: 11 | sys.path.append(module_path) 12 | 13 | import msda 14 | 15 | 16 | def create_user_homes(num, location): 17 | created_user_homes = [] 18 | for n in range(num): 19 | user_home_path = os.path.join(location, fake.user_name()) 20 | os.makedirs(user_home_path) 21 | created_user_homes.append(user_home_path) 22 | 23 | return created_user_homes 24 | 25 | class LSHandlerFactory(factory.Factory): 26 | 27 | class Meta: 28 | model = msda.LSHandler 29 | 30 | class Params: 31 | use_all = True 32 | extension_only = False 33 | protocol_only = False 34 | uti_only = False 35 | 36 | app_id = fake_app_id() 37 | 38 | @factory.lazy_attribute 39 | def uti(self): 40 | rand_num = random() 41 | if self.extension_only: 42 | return msda.EXTENSION_UTI 43 | elif self.protocol_only: 44 | return fake_protocol() 45 | elif self.uti_only: 46 | return fake_uti() 47 | elif rand_num < 0.1: 48 | return msda.EXTENSION_UTI 49 | elif rand_num <= 0.55: 50 | return fake_protocol() 51 | elif rand_num > 0.55: 52 | return fake_uti() 53 | 54 | @factory.lazy_attribute 55 | def role(self): 56 | if '.' in self.uti: 57 | return fake_role(all=self.use_all) 58 | return None 59 | 60 | @factory.lazy_attribute 61 | def extension(self): 62 | if self.uti == msda.EXTENSION_UTI: 63 | return fake_extension() 64 | return None 65 | -------------------------------------------------------------------------------- /tests/fake/filesystem/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import imp 4 | import os as real_os 5 | import shutil 6 | import tempfile 7 | from unittest import TestCase 8 | 9 | from faker import Faker 10 | from faker.providers import internet 11 | 12 | fake = Faker() 13 | fake.add_provider(internet) 14 | 15 | TMP_PREFIX = 'fake_fs_' 16 | USER_HOMES_DIR_NAME = 'Users' 17 | USER_TEMPLATE_PATH = real_os.path.join( 18 | 'Library', 19 | 'User Template', 20 | 'English.lproj', 21 | ) 22 | 23 | BASE_PATHS = [ 24 | real_os.path.join(USER_HOMES_DIR_NAME, '.localized'), 25 | real_os.path.join(USER_HOMES_DIR_NAME, 'Shared', '.localized'), 26 | ] 27 | 28 | BASE_USER_PATHS = ( 29 | real_os.path.join('Library', 'Preferences', 'com.apple.launchservices'), 30 | ) 31 | 32 | for path in BASE_USER_PATHS: 33 | BASE_PATHS.append(real_os.path.join( 34 | USER_TEMPLATE_PATH, path, 35 | )) 36 | 37 | class MockOS(object): 38 | 39 | def __getattr__(self, attr): 40 | print('mock os call') 41 | return getattr(real_os, attr) 42 | 43 | class FakeFSTestCase(TestCase): 44 | 45 | def create_base_fs(self, paths, root=None): 46 | if not root: 47 | root = self.ROOT_DIR 48 | 49 | for path in paths: 50 | real_os.makedirs(real_os.path.join( 51 | root, 52 | path, 53 | )) 54 | 55 | def setUp(self): 56 | self.ROOT_DIR = tempfile.mkdtemp(prefix=TMP_PREFIX) 57 | self.create_base_fs(BASE_PATHS) 58 | 59 | def tearDown(self): 60 | shutil.rmtree(self.ROOT_DIR) 61 | 62 | @property 63 | def USER_HOMES_DIR(self): 64 | return real_os.path.join( 65 | self.ROOT_DIR, 66 | USER_HOMES_DIR_NAME, 67 | ) 68 | 69 | def create_user_homes(self, number=1): 70 | created_user_homes = [] 71 | for n in range(number): 72 | user_home_path = real_os.path.join( 73 | self.USER_HOMES_DIR, 74 | fake.user_name(), 75 | ) 76 | self.create_base_fs(BASE_USER_PATHS, root=user_home_path) 77 | created_user_homes.append(user_home_path) 78 | return created_user_homes 79 | -------------------------------------------------------------------------------- /tests/assets/ex_xml.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSHandlers 6 | 7 | 8 | LSHandlerContentType 9 | public.url 10 | LSHandlerRoleViewer 11 | com.company.fakebrowser 12 | LSHandlerPreferredVersions 13 | 14 | LSHandlerRoleViewer 15 | - 16 | 17 | 18 | 19 | LSHandlerContentType 20 | com.company.uti 21 | LSHandlerRoleAll 22 | com.company.fakeapp.pro 23 | LSHandlerPreferredVersions 24 | 25 | LSHandlerRoleAll 26 | - 27 | 28 | 29 | 30 | LSHandlerURLScheme 31 | mailto 32 | LSHandlerRoleAll 33 | com.company.fakemailer 34 | LSHandlerPreferredVersions 35 | 36 | LSHandlerRoleAll 37 | - 38 | 39 | 40 | 41 | LSHandlerContentType 42 | public.html 43 | LSHandlerRoleAll 44 | com.company.fakebrowser 45 | LSHandlerPreferredVersions 46 | 47 | LSHandlerRoleAll 48 | - 49 | 50 | 51 | 52 | LSHandlerURLScheme 53 | https 54 | LSHandlerRoleAll 55 | com.company.fakebrowser 56 | LSHandlerPreferredVersions 57 | 58 | LSHandlerRoleAll 59 | - 60 | 61 | 62 | 63 | LSHandlerURLScheme 64 | http 65 | LSHandlerRoleAll 66 | com.company.fakebrowser 67 | LSHandlerPreferredVersions 68 | 69 | LSHandlerRoleAll 70 | - 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: macOS-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Get the version number 14 | id: get_version 15 | run: echo "##[set-output name=version;]$(./payload/msda --version 2>&1)" 16 | - name: Build the package 17 | run: ./build.sh 18 | - name: Upload built package 19 | uses: actions/upload-artifact@master 20 | with: 21 | name: ${{ format('MacSetDefaultApps-v{0}.pkg', steps.get_version.outputs.version) }} 22 | path: ${{ format('./MacSetDefaultApps-v{0}.pkg', steps.get_version.outputs.version) }} 23 | 24 | release: 25 | runs-on: macOS-latest 26 | needs: build 27 | steps: 28 | - uses: actions/checkout@v1 29 | - name: Get the version number 30 | id: get_version 31 | run: echo "##[set-output name=version;]$(./payload/msda --version 2>&1)" 32 | - name: Download built package 33 | uses: actions/download-artifact@v1 34 | with: 35 | name: ${{ format('MacSetDefaultApps-v{0}.pkg', steps.get_version.outputs.version) }} 36 | path: '.' 37 | - name: Create release 38 | id: create_release 39 | uses: actions/create-release@v1.0.0 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | tag_name: ${{ format('v{0}', steps.get_version.outputs.version) }} 44 | release_name: ${{ format('v{0}', steps.get_version.outputs.version) }} 45 | draft: false 46 | prerelease: false 47 | - name: Upload built package to release 48 | uses: actions/upload-release-asset@v1.0.1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | upload_url: ${{ steps.create_release.outputs.upload_url }} 53 | asset_path: ${{ format('./MacSetDefaultApps-v{0}.pkg', steps.get_version.outputs.version) }} 54 | asset_name: ${{ format('MacSetDefaultApps-v{0}.pkg', steps.get_version.outputs.version) }} 55 | asset_content_type: application/vnd.apple.installer+xml 56 | -------------------------------------------------------------------------------- /tests/fake_filesystem_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | 5 | import unittest 6 | from unittest import TestCase 7 | 8 | import os 9 | import sys 10 | from random import randint 11 | 12 | import mock 13 | 14 | from fake.filesystem import ( 15 | BASE_PATHS, BASE_USER_PATHS, FakeFSTestCase, USER_HOMES_DIR_NAME, 16 | USER_TEMPLATE_PATH, MockOS 17 | ) 18 | from utils.settings import * 19 | 20 | module_path = os.path.abspath(os.path.join(THIS_FILE, '../payload')) 21 | if module_path not in sys.path: 22 | sys.path.append(module_path) 23 | 24 | import msda 25 | 26 | class TestFakeFileSystemFunctions(FakeFSTestCase): 27 | 28 | def test_all_base_folders_are_created(self): 29 | for path in BASE_PATHS: 30 | self.assertTrue(os.path.exists(os.path.join( 31 | self.ROOT_DIR, 32 | path, 33 | ))) 34 | 35 | def test_creates_English_user_template_by_default(self): 36 | self.assertTrue(os.path.exists(os.path.join( 37 | self.ROOT_DIR, 38 | USER_TEMPLATE_PATH, 39 | ))) 40 | 41 | def test_base_user_folders_are_created_in_English_user_template(self): 42 | for path in BASE_USER_PATHS: 43 | self.assertTrue(os.path.exists(os.path.join( 44 | self.ROOT_DIR, 45 | USER_TEMPLATE_PATH, 46 | path, 47 | ))) 48 | 49 | def test_can_create_single_user_home(self): 50 | user_homes = self.create_user_homes(1) 51 | self.assertTrue(os.path.exists(user_homes[0])) 52 | 53 | def test_can_create_multiple_user_homes(self): 54 | num_homes = randint(2, 10) 55 | user_homes = self.create_user_homes(num_homes) 56 | self.assertEqual(num_homes, len(user_homes)) 57 | for user_home in user_homes: 58 | self.assertTrue(os.path.exists(user_home)) 59 | 60 | def test_user_homes_dont_persist_between_tests(self): 61 | self.assertEqual( 62 | os.listdir(self.USER_HOMES_DIR), 63 | ['.localized', 'Shared'], 64 | ) 65 | 66 | def test_created_user_homes_have_base_structure(self): 67 | user_homes = self.create_user_homes(randint(1, 10)) 68 | for user_home in user_homes: 69 | for path in BASE_USER_PATHS: 70 | self.assertTrue(os.path.exists(os.path.join( 71 | user_home, path 72 | ))) 73 | 74 | class TestMockOSFunctions(TestCase): 75 | 76 | def test_pass_through(self): 77 | print(MockOS().path.exists('/')) 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > Please look at [default-browser](https://github.com/macadmins/default-browser) or [utiluti](https://github.com/scriptingosx/utiluti) instead of using MSDA. Both tools are much more actively developed, and are safer and easier to use. 3 | 4 | # Mac Set Default Apps (MSDA) 5 | 6 | MSDA provides an easy way to silently change the default applications used by macOS. There are no pop-ups or prompts and, even better, it works for Google Chrome! 7 | 8 | ## Requirements 9 | * macOS 10.14.0 - macOS 12.2.1 10 | * macOS 12.3.0 or newer with Python installed 11 | 12 | ## Deploying 13 | ### As a Local Installation 14 | 1. Download the latest packaged release of MSDA [here](https://github.com/targendaz2/Mac-Set-Default-Apps/releases) 15 | 2. Install the package on as many target Macs as needed, either manually or through a system such as Munki or Jamf 16 | 17 | ### As a Jamf Script 18 | **Note:** I assume these instructions will also work for MDM services other than Jamf, I just only have familiarity with Jamf 19 | 20 | 1. Copy the contents of [payload/msda.py](https://github.com/targendaz2/Mac-Set-Default-Apps/blob/master/payload/msda.py) into a new Jamf script. 21 | 2. In the script's User-Editable Settings section, set the `JAMF` variable to `True`. 22 | 23 | ## Usage 24 | **Note:** If using MSDA as a Jamf script, add these arguments in the Parameter 4 text field when assigning the script to a policy, excluding the initial `msda` 25 | 26 | ### Base Usage 27 | ``` 28 | msda command -h --version 29 | ``` 30 | 31 | * command can only be the following, for now 32 | * set: Set an application as a default 33 | * -h, --help: Show MSDA's help message 34 | * --version: Print the current version of MSDA 35 | 36 | ### Set Command Usage 37 | **Note:** The `set` command must be run with administrative privileges 38 | 39 | ``` 40 | msda set [-h] [-feu] [-fut] [-e EXTENSION ROLE] [-p PROTOCOL] [-u UTI ROLE] app_id 41 | ``` 42 | 43 | * app_id: The ID of the application to set as a default app 44 | * -h, --help: Show help for the `set` command 45 | * -feu: Apply the specified changes to all existing users 46 | * -fut: Apply the specified changes to the `English.lproj` user template in addition to the currently logged on user (if there is one) 47 | * -e, --extension: 48 | * EXTENSION: Specifiy a file extension that the specified app should handle 49 | * ROLE: The scenario under which the specified app should handle this UTI 50 | * -p, --protocol: Specify a protocol that the specified app should handle 51 | * -u, --uti: 52 | * UTI: Specifiy a UTI that the specified app should handle 53 | * ROLE: The scenario under which the specified app should handle this UTI 54 | 55 | ### Examples 56 | 57 | Set Google Chrome as the default web browser for the current user and the user template 58 | ``` 59 | msda set com.google.chrome -p http -p https -u public.url all -u public.html viewer -u public.xhtml all -fut 60 | ``` 61 | 62 | Set Microsoft Outlook as the default email and calendar client for all existing users and the user template 63 | ``` 64 | msda set com.microsoft.outlook -p mailto -p webcal -u com.apple.ical.ics all -u com.apple.ical.vcs all -feu -fut 65 | ``` 66 | 67 | Set Adobe Acrobat as the default PDF reader for the current user only 68 | ``` 69 | msda set com.adobe.acrobat.pro -u com.adobe.pdf all 70 | ``` 71 | 72 | Set Microsoft Edge as the default web browser for just the current user 73 | ``` 74 | msda set com.microsoft.edgemac -p http -p https -u public.url all -u public.html viewer -u public.xhtml all 75 | ``` 76 | 77 | Set Google Chrome as the default web browser and email client for the current user and the user template 78 | ``` 79 | msda set com.google.chrome -p http -p https -p mailto -u public.url all -u public.html viewer -u public.xhtml all -fut 80 | ``` 81 | 82 | ## FAQ 83 | 84 | How can I find an application's ID? 85 | > Run `osascript -e 'id of app "Name of App"'` in a Terminal window, replacing the text between the double quotes with the name of the application in question. 86 | 87 | How can I figure out what protocols or UTIs to set? 88 | > I've tried to include the most common examples above. Otherwise, a complete list of protocols can be found [here](https://en.wikipedia.org/wiki/List_of_URI_schemes), and UTIs [here](https://escapetech.eu/manuals/qdrop/uti.html). 89 | 90 | Are there commands other than `set`? 91 | > At the moment, no. Please [create an issue](https://github.com/targendaz2/Mac-Set-Default-Apps/issues/new) on this app's GitHub page if there are commands you'd find useful. 92 | 93 | Why aren't there any examples of setting a default app for a file extension? 94 | > I haven't found any apps that require this to be set as a default app. This feature was included solely to prevent MSDA from malfunctioning when being used on a Mac where a default app was already assigned to a file extension. 95 | 96 | Where can I go for help with this app? 97 | > If you need help with this app specifically, please feel free to [create an issue](https://github.com/targendaz2/Mac-Set-Default-Apps/issues/new) on this app's GitHub page. I'll try to either respond, or implement changes to the app as soon as possible. 98 | 99 | What about help with other Mac-related things? 100 | >[Jamf Nation](https://www.jamf.com/jamf-nation/), the [MacSysAdmin subreddit](https://www.reddit.com/r/macsysadmin/), and the [MacAdmins Slack channel](https://macadmins.slack.com) are all great resources for help managing Macs in an enterprise environment. 101 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | 5 | import unittest 6 | from unittest import TestCase 7 | 8 | import os 9 | from random import randint, random 10 | import shutil 11 | import sys 12 | import tempfile 13 | 14 | import mock 15 | 16 | from utils.classes import * 17 | from utils.settings import * 18 | 19 | module_path = os.path.abspath(os.path.join(THIS_FILE, '../payload')) 20 | if module_path not in sys.path: 21 | sys.path.append(module_path) 22 | 23 | import msda 24 | 25 | 26 | class TestLaunchServicesTestCaseSetUpAndTearDown(LaunchServicesTestCase): 27 | 28 | def setUp(self): 29 | pass 30 | 31 | def tearDown(self): 32 | pass 33 | 34 | def test_setUp_creates_tmp_directory(self): 35 | super(TestLaunchServicesTestCaseSetUpAndTearDown, self).setUp() 36 | self.assertIsNotNone(self.tmp) 37 | self.assertTrue(os.path.exists(self.tmp)) 38 | 39 | def test_tearDown_removes_tmp_directory(self): 40 | super(TestLaunchServicesTestCaseSetUpAndTearDown, self).setUp() 41 | super(TestLaunchServicesTestCaseSetUpAndTearDown, self).tearDown() 42 | self.assertFalse(os.path.exists(self.tmp)) 43 | 44 | 45 | class TestLaunchServicesTestCaseMethods(LaunchServicesTestCase): 46 | 47 | def test_seed_plist_copies_plist_into_tmp(self): 48 | self.assertTrue(os.path.exists(os.path.join( 49 | THIS_FILE, 'assets', SIMPLE_BINARY_PLIST, 50 | ))) 51 | tmp_path = self.seed_plist(SIMPLE_BINARY_PLIST) 52 | self.assertTrue(os.path.exists(tmp_path)) 53 | 54 | 55 | class TestFunctions(LaunchServicesTestCase): 56 | 57 | def test_gather_user_ls_paths(self): 58 | fake_user_homes = create_user_homes(3, self.tmp) 59 | for fake_user_home in fake_user_homes: 60 | self.seed_plist( 61 | SIMPLE_BINARY_PLIST, 62 | os.path.join(fake_user_home, msda.PLIST_RELATIVE_LOCATION), 63 | msda.PLIST_NAME, 64 | ) 65 | 66 | with mock.patch('msda.USER_HOMES_LOCATION', self.tmp): 67 | gathered_ls_paths = msda.gather_user_ls_paths() 68 | 69 | for fake_user_home in fake_user_homes: 70 | fake_ls_path = os.path.join( 71 | fake_user_home, 72 | msda.PLIST_RELATIVE_LOCATION, 73 | msda.PLIST_NAME 74 | ) 75 | self.assertIn(fake_ls_path, gathered_ls_paths) 76 | 77 | 78 | class TestLSHandlerObject(TestCase): 79 | 80 | def test_LSHandler_can_be_converted_to_dict(self): 81 | sample_lshandler = LSHandlerFactory() 82 | self.assertIsInstance(dict(sample_lshandler), dict) 83 | 84 | def test_can_generate_LSHandler_for_uti(self): 85 | sample_lshandler = LSHandlerFactory(uti_only=True) 86 | sample_dict = dict(sample_lshandler) 87 | self.assertIn(sample_lshandler.app_id, sample_dict.values()) 88 | self.assertIn(sample_lshandler.uti, sample_dict.values()) 89 | self.assertIn( 90 | 'LSHandlerRole' + sample_lshandler.role.capitalize(), 91 | sample_dict.keys() 92 | ) 93 | 94 | def test_can_generate_LSHandler_for_protocol(self): 95 | sample_lshandler = LSHandlerFactory(protocol_only=True) 96 | sample_dict = dict(sample_lshandler) 97 | self.assertIn(sample_lshandler.app_id, sample_dict.values()) 98 | self.assertIn(sample_lshandler.uti, sample_dict.values()) 99 | self.assertIn('LSHandlerRoleAll', sample_dict.keys()) 100 | 101 | def test_can_generate_LSHandler_for_extension(self): 102 | sample_lshandler = LSHandlerFactory(extension_only=True) 103 | self.assertEqual(sample_lshandler.uti, msda.EXTENSION_UTI) 104 | 105 | sample_dict = dict(sample_lshandler) 106 | self.assertIn(sample_lshandler.app_id, sample_dict.values()) 107 | self.assertIn(sample_lshandler.uti, sample_dict.values()) 108 | self.assertIn( 109 | 'LSHandlerRole' + sample_lshandler.role.capitalize(), 110 | sample_dict.keys() 111 | ) 112 | self.assertIn('LSHandlerContentTag', sample_dict.keys()) 113 | self.assertIn(sample_lshandler.extension, sample_dict.values()) 114 | 115 | 116 | class TestLSHandlerObjectEquality(TestCase): 117 | 118 | def test_equal_if_same_uti_and_role(self): 119 | uti = fake_uti() 120 | role = fake_role(all=False) 121 | self.assertEqual( 122 | LSHandlerFactory(uti=uti, role=role), 123 | LSHandlerFactory(uti=uti, role=role), 124 | ) 125 | 126 | def test_not_equal_if_different_uti(self): 127 | self.assertNotEqual( 128 | LSHandlerFactory(uti='public.html'), 129 | LSHandlerFactory(uti='mailto'), 130 | ) 131 | 132 | def test_not_equal_if_different_role_but_neither_is_all(self): 133 | uti = fake_uti() 134 | self.assertNotEqual( 135 | LSHandlerFactory(uti=uti, role='viewer'), 136 | LSHandlerFactory(uti=uti, role='editor'), 137 | ) 138 | 139 | def test_equal_if_same_uti_and_existing_role_is_all(self): 140 | uti = fake_uti() 141 | self.assertEqual( 142 | LSHandlerFactory(uti=uti, role='all'), 143 | LSHandlerFactory(uti=uti, use_all=False), 144 | ) 145 | 146 | def test_equal_if_same_uti_and_replacing_role_is_all(self): 147 | uti = fake_uti() 148 | self.assertEqual( 149 | LSHandlerFactory(uti=uti, use_all=False), 150 | LSHandlerFactory(uti=uti, role='all'), 151 | ) 152 | 153 | def test_not_equal_if_different_extension(self): 154 | uti = msda.EXTENSION_UTI 155 | self.assertNotEqual( 156 | LSHandlerFactory(uti=uti, extension='pdf'), 157 | LSHandlerFactory(uti=uti, extension='cr'), 158 | ) 159 | 160 | def test_not_equal_if_same_extension_and_different_not_all_role(self): 161 | uti = msda.EXTENSION_UTI 162 | extension = fake_extension() 163 | self.assertNotEqual( 164 | LSHandlerFactory( 165 | uti=uti, 166 | extension=extension, 167 | role='viewer', 168 | ), 169 | LSHandlerFactory( 170 | uti=uti, 171 | extension=extension, 172 | role='editor', 173 | ), 174 | ) 175 | 176 | def test_not_equal_if_same_extension_and_existing_role_is_all(self): 177 | uti = msda.EXTENSION_UTI 178 | extension = fake_extension() 179 | self.assertEqual( 180 | LSHandlerFactory( 181 | uti=uti, 182 | extension=extension, 183 | role='all', 184 | ), 185 | LSHandlerFactory( 186 | uti=uti, 187 | extension=extension, 188 | role='editor', 189 | ), 190 | ) 191 | 192 | def test_not_equal_if_same_extension_and_replacing_role_is_all(self): 193 | uti = msda.EXTENSION_UTI 194 | extension = fake_extension() 195 | self.assertEqual( 196 | LSHandlerFactory( 197 | uti=uti, 198 | extension=extension, 199 | role='viewer', 200 | ), 201 | LSHandlerFactory( 202 | uti=uti, 203 | extension=extension, 204 | role='all', 205 | ), 206 | ) 207 | 208 | 209 | class TestLaunchServicesObject(LaunchServicesTestCase): 210 | 211 | def setUp(self): 212 | super(TestLaunchServicesObject, self).setUp() 213 | self.ls = msda.LaunchServices() 214 | self.ls2 = msda.LaunchServices() 215 | 216 | def test_can_read_binary_plists(self): 217 | self.ls.plist = self.seed_plist(SIMPLE_BINARY_PLIST) 218 | 219 | self.ls.read() 220 | self.assertEqual(dict(self.ls), EMPTY_LS_PLIST) 221 | 222 | def test_returns_base_plist_if_non_existant(self): 223 | self.ls.plist = os.path.join(self.tmp, SIMPLE_BINARY_PLIST) 224 | self.assertFalse(os.path.isfile(self.ls.plist)) 225 | 226 | self.ls.read() 227 | self.assertEqual(dict(self.ls), EMPTY_LS_PLIST) 228 | 229 | def test_can_write_binary_plists(self): 230 | self.ls.plist = self.seed_plist(XML_PLIST) 231 | dest_plist = os.path.join(self.tmp, 'tmp.secure.plist') 232 | self.ls.read() 233 | self.ls.write(dest_plist) 234 | 235 | self.ls2.plist = dest_plist 236 | self.ls2.read() 237 | self.assertEqual(dict(self.ls), dict(self.ls2)) 238 | 239 | def test_can_write_binary_plists_if_directory_structure_doesnt_exist(self): 240 | self.ls.plist = self.seed_plist(XML_PLIST) 241 | dest_plist = os.path.join(self.tmp, 'new.dir/tmp.secure.plist') 242 | self.ls.read() 243 | self.ls.write(dest_plist) 244 | 245 | self.ls2.plist = dest_plist 246 | self.ls2.read() 247 | self.assertEqual(dict(self.ls), dict(self.ls2)) 248 | 249 | def test_stores_LSHandlers_in_contained_list(self): 250 | src_plist = self.seed_plist(BINARY_PLIST) 251 | self.ls.plist = src_plist 252 | self.ls.read() 253 | self.assertIsInstance(self.ls.handlers[0], msda.LSHandler) 254 | 255 | def test_populates_self_if_provided_plist(self): 256 | ls = msda.LaunchServices(self.seed_plist(BINARY_PLIST)) 257 | self.assertIsInstance(ls.handlers[0], msda.LSHandler) 258 | 259 | def test_can_set_handlers(self): 260 | ls = msda.LaunchServices(self.seed_plist(SIMPLE_BINARY_PLIST)) 261 | ls.set_handler( 262 | app_id='edu.school.browser', 263 | uti='public.html', 264 | role='viewer', 265 | ) 266 | expected_handler = msda.LSHandler( 267 | app_id='edu.school.browser', 268 | uti='public.html', 269 | role='viewer', 270 | ) 271 | 272 | self.assertEqual(dict(ls.handlers[0]), dict(expected_handler)) 273 | 274 | def test_can_check_for_set_app_IDs(self): 275 | ls = msda.LaunchServices(self.seed_plist(SIMPLE_BINARY_PLIST)) 276 | ls.set_handler( 277 | app_id='edu.school.browser', 278 | uti='public.html', 279 | role='viewer', 280 | ) 281 | ls.set_handler( 282 | app_id='edu.school.email', 283 | uti='mailto', 284 | ) 285 | 286 | self.assertIn('edu.school.browser', ls.app_ids) 287 | self.assertIn('edu.school.email', ls.app_ids) 288 | 289 | def test_overwrites_single_handler_for_same_uti_and_role(self): 290 | ls = msda.LaunchServices(self.seed_plist(SIMPLE_BINARY_PLIST)) 291 | old_handler = ls.set_handler( 292 | app_id='edu.school.browser', 293 | uti='public.html', 294 | role='viewer', 295 | ) 296 | 297 | self.assertIn(old_handler.app_id, ls.app_ids) 298 | 299 | new_handler = ls.set_handler( 300 | app_id='org.bigorg.browser', 301 | uti='public.html', 302 | role='viewer', 303 | ) 304 | 305 | self.assertNotIn(old_handler.app_id, ls.app_ids) 306 | self.assertIn(new_handler.app_id, ls.app_ids) 307 | 308 | def test_overwrites_all_handlers_for_same_uti_and_role(self): 309 | ls = msda.LaunchServices(self.seed_plist(SIMPLE_BINARY_PLIST)) 310 | old_handlers = [ 311 | ls.set_handler( 312 | app_id='edu.school.browser', 313 | uti='public.html', 314 | role='viewer', 315 | ), 316 | ls.set_handler( 317 | app_id='edu.school.browser', 318 | uti='public.html', 319 | role='reader', 320 | ), 321 | ls.set_handler( 322 | app_id='edu.school.browser', 323 | uti='public.html', 324 | role='writer', 325 | ), 326 | ] 327 | 328 | for old_handler in old_handlers: 329 | self.assertIn(old_handler.app_id, ls.app_ids) 330 | 331 | new_handler = ls.set_handler( 332 | app_id='org.bigorg.browser', 333 | uti='public.html', 334 | role='all', 335 | ) 336 | 337 | for old_handler in old_handlers: 338 | self.assertNotIn(old_handler.app_id, ls.app_ids) 339 | self.assertIn(new_handler.app_id, ls.app_ids) 340 | 341 | def test_can_set_LSHandler_from_object(self): 342 | ls = msda.LaunchServices(self.seed_plist(SIMPLE_BINARY_PLIST)) 343 | handler = LSHandlerFactory(use_all=True) 344 | 345 | self.assertNotIn(handler, ls.handlers) 346 | ls.set_handler(handler) 347 | self.assertIn(handler, ls.handlers) 348 | 349 | if __name__ == '__main__': 350 | unittest.main() 351 | -------------------------------------------------------------------------------- /tests/functional_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | 5 | import unittest 6 | 7 | import os 8 | import sys 9 | from random import randint, random 10 | 11 | import mock 12 | 13 | from utils.classes import * 14 | from utils.settings import * 15 | 16 | module_path = os.path.abspath(os.path.join(THIS_FILE, '../payload')) 17 | if module_path not in sys.path: 18 | sys.path.append(module_path) 19 | 20 | import msda 21 | 22 | 23 | class FunctionalTests(LaunchServicesTestCase): 24 | 25 | def setUp(self): 26 | super(FunctionalTests, self).setUp() 27 | self.user_ls_path = self.seed_plist(SIMPLE_BINARY_PLIST) 28 | self.user_ls = msda.LaunchServices(self.user_ls_path) 29 | self.template_ls_path = os.path.join( 30 | self.tmp, 'com.apple.LaunchServices.Secure.plist' 31 | ) 32 | self.template_ls = msda.LaunchServices(self.template_ls_path) 33 | 34 | @mock.patch('msda.create_user_ls_path') 35 | def test_set_single_uti_handler_for_current_user(self, user_fn): 36 | user_fn.return_value = self.user_ls_path 37 | handler = LSHandlerFactory(uti_only=True) 38 | 39 | self.assertNotIn(handler, self.user_ls.handlers) 40 | 41 | arguments = [ 42 | 'set', 43 | handler.app_id, 44 | '-u', handler.uti, handler.role, 45 | ] 46 | msda.main(arguments) 47 | 48 | self.user_ls.read() 49 | self.assertIn(handler, self.user_ls.handlers) 50 | 51 | @mock.patch('msda.create_user_ls_path') 52 | def test_set_single_protocol_handler_for_current_user(self, user_fn): 53 | user_fn.return_value = self.user_ls_path 54 | handler = LSHandlerFactory(protocol_only=True) 55 | 56 | self.assertNotIn(handler, self.user_ls.handlers) 57 | 58 | arguments = [ 59 | 'set', 60 | handler.app_id, 61 | '-p', handler.uti, 62 | ] 63 | msda.main(arguments) 64 | 65 | self.user_ls.read() 66 | self.assertIn(handler, self.user_ls.handlers) 67 | 68 | @mock.patch('msda.create_user_ls_path') 69 | def test_set_multiple_uti_handlers_for_current_user(self, user_fn): 70 | user_fn.return_value = self.user_ls_path 71 | handlers = LSHandlerFactory.build_batch(randint(3, 6), uti_only=True) 72 | 73 | arguments = ['set', handlers[0].app_id] 74 | for handler in handlers: 75 | self.assertNotIn(handler, self.user_ls.handlers) 76 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 77 | 78 | arguments.extend(['-u', handler.uti, handler.role,]) 79 | msda.main(arguments) 80 | 81 | self.user_ls.read() 82 | for handler in handlers: 83 | self.assertIn(handler, self.user_ls.handlers) 84 | self.assertIn(handler.app_id, self.user_ls.app_ids) 85 | 86 | @mock.patch('msda.create_user_ls_path') 87 | def test_set_multiple_protocol_handlers_for_current_user(self, user_fn): 88 | user_fn.return_value = self.user_ls_path 89 | handlers = LSHandlerFactory.build_batch(randint(3, 6), protocol_only=True) 90 | 91 | arguments = ['set', handlers[0].app_id] 92 | for handler in handlers: 93 | self.assertNotIn(handler, self.user_ls.handlers) 94 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 95 | 96 | arguments.extend(['-p', handler.uti]) 97 | msda.main(arguments) 98 | 99 | self.user_ls.read() 100 | for handler in handlers: 101 | self.assertIn(handler, self.user_ls.handlers) 102 | self.assertIn(handler.app_id, self.user_ls.app_ids) 103 | 104 | @mock.patch('msda.create_user_ls_path') 105 | def test_set_multiple_mixed_handlers_for_current_user(self, user_fn): 106 | user_fn.return_value = self.user_ls_path 107 | handlers = LSHandlerFactory.build_batch(randint(3, 6)) 108 | 109 | arguments = ['set', handlers[0].app_id] 110 | for handler in handlers: 111 | self.assertNotIn(handler, self.user_ls.handlers) 112 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 113 | 114 | if '.' in handler.uti: 115 | if handler.uti == msda.EXTENSION_UTI: 116 | arguments.extend(['-e', handler.extension, handler.role]) 117 | else: 118 | arguments.extend(['-u', handler.uti, handler.role]) 119 | else: 120 | arguments.extend(['-p', handler.uti]) 121 | msda.main(arguments) 122 | 123 | self.user_ls.read() 124 | for handler in handlers: 125 | self.assertIn(handler, self.user_ls.handlers) 126 | self.assertIn(handler.app_id, self.user_ls.app_ids) 127 | 128 | @mock.patch('msda.create_user_ls_path') 129 | @mock.patch('msda.create_template_ls_path') 130 | def test_set_handlers_for_current_user_and_template(self, template_fn, user_fn): 131 | user_fn.return_value = self.user_ls_path 132 | template_fn.return_value = self.template_ls_path 133 | handlers = LSHandlerFactory.build_batch(randint(4, 6)) 134 | 135 | arguments = ['set', '-fut', handlers[0].app_id] 136 | for handler in handlers: 137 | self.assertNotIn(handler, self.user_ls.handlers) 138 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 139 | self.assertNotIn(handler, self.template_ls.handlers) 140 | self.assertNotIn(handler.app_id, self.template_ls.app_ids) 141 | 142 | if '.' in handler.uti: 143 | if handler.uti == msda.EXTENSION_UTI: 144 | arguments.extend(['-e', handler.extension, handler.role]) 145 | else: 146 | arguments.extend(['-u', handler.uti, handler.role]) 147 | else: 148 | arguments.extend(['-p', handler.uti]) 149 | msda.main(arguments) 150 | 151 | self.user_ls.read() 152 | self.template_ls.read() 153 | for handler in handlers: 154 | self.assertIn(handler, self.user_ls.handlers) 155 | self.assertIn(handler.app_id, self.user_ls.app_ids) 156 | self.assertIn(handler, self.template_ls.handlers) 157 | self.assertIn(handler.app_id, self.template_ls.app_ids) 158 | 159 | @mock.patch('msda.create_user_ls_path') 160 | @mock.patch('msda.create_template_ls_path') 161 | @mock.patch('msda.get_current_username', return_value='') 162 | def test_set_handlers_for_only_template(self, 163 | get_current_username, template_fn, user_fn, 164 | ): 165 | user_fn.return_value = self.user_ls_path 166 | template_fn.return_value = self.template_ls_path 167 | handlers = LSHandlerFactory.build_batch(randint(4, 6)) 168 | 169 | arguments = ['set', '-fut', handlers[0].app_id] 170 | for handler in handlers: 171 | self.assertNotIn(handler, self.user_ls.handlers) 172 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 173 | self.assertNotIn(handler, self.template_ls.handlers) 174 | self.assertNotIn(handler.app_id, self.template_ls.app_ids) 175 | 176 | if '.' in handler.uti: 177 | if handler.uti == msda.EXTENSION_UTI: 178 | arguments.extend(['-e', handler.extension, handler.role]) 179 | else: 180 | arguments.extend(['-u', handler.uti, handler.role]) 181 | else: 182 | arguments.extend(['-p', handler.uti]) 183 | msda.main(arguments) 184 | 185 | self.user_ls.read() 186 | self.template_ls.read() 187 | for handler in handlers: 188 | self.assertNotIn(handler, self.user_ls.handlers) 189 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 190 | self.assertIn(handler, self.template_ls.handlers) 191 | self.assertIn(handler.app_id, self.template_ls.app_ids) 192 | 193 | @mock.patch('msda.create_user_ls_path') 194 | @mock.patch('msda.create_template_ls_path') 195 | @mock.patch('msda.JAMF', True) 196 | def test_set_handlers_for_current_user_and_template_in_Jamf(self, 197 | template_fn, user_fn, 198 | ): 199 | user_fn.return_value = self.user_ls_path 200 | template_fn.return_value = self.template_ls_path 201 | handlers = LSHandlerFactory.build_batch(randint(1, 3)) 202 | 203 | arguments = ['', '', '', 'set -fut ' + handlers[0].app_id] 204 | for handler in handlers: 205 | self.assertNotIn(handler, self.user_ls.handlers) 206 | self.assertNotIn(handler.app_id, self.user_ls.app_ids) 207 | self.assertNotIn(handler, self.template_ls.handlers) 208 | self.assertNotIn(handler.app_id, self.template_ls.app_ids) 209 | 210 | if '.' in handler.uti: 211 | if handler.uti == msda.EXTENSION_UTI: 212 | arguments[3] += ' -e ' + handler.extension + ' ' + handler.role 213 | else: 214 | arguments[3] += ' -u ' + handler.uti + ' ' + handler.role 215 | else: 216 | arguments[3] += ' -p ' + handler.uti 217 | msda.main(arguments) 218 | 219 | self.user_ls.read() 220 | self.template_ls.read() 221 | for handler in handlers: 222 | self.assertIn(handler, self.user_ls.handlers) 223 | self.assertIn(handler.app_id, self.user_ls.app_ids) 224 | self.assertIn(handler, self.template_ls.handlers) 225 | self.assertIn(handler.app_id, self.template_ls.app_ids) 226 | 227 | def test_set_handlers_for_all_existing_users(self,): 228 | fake_user_home_location = os.path.join(self.tmp, 'Users') 229 | fake_user_homes = create_user_homes(randint(1, 3), fake_user_home_location) 230 | handlers = LSHandlerFactory.build_batch(randint(4, 6)) 231 | arguments = ['set', '-feu', handlers[0].app_id] 232 | 233 | for handler in handlers: 234 | if '.' in handler.uti: 235 | if handler.uti == msda.EXTENSION_UTI: 236 | arguments.extend(['-e', handler.extension, handler.role]) 237 | else: 238 | arguments.extend(['-u', handler.uti, handler.role]) 239 | else: 240 | arguments.extend(['-p', handler.uti]) 241 | 242 | for user_home in fake_user_homes: 243 | user_ls_path = self.seed_plist( 244 | SIMPLE_BINARY_PLIST, 245 | os.path.join(user_home, msda.PLIST_RELATIVE_LOCATION), 246 | msda.PLIST_NAME, 247 | ) 248 | user_ls = msda.LaunchServices(user_ls_path) 249 | for handler in handlers: 250 | self.assertNotIn(handler, user_ls.handlers) 251 | self.assertNotIn(handler.app_id, user_ls.app_ids) 252 | 253 | with mock.patch('msda.USER_HOMES_LOCATION', fake_user_home_location): 254 | msda.main(arguments) 255 | 256 | for user_home in fake_user_homes: 257 | user_ls.read() 258 | for handler in handlers: 259 | self.assertIn(handler, user_ls.handlers) 260 | self.assertIn(handler.app_id, user_ls.app_ids) 261 | 262 | @mock.patch('msda.create_template_ls_path') 263 | def test_set_handlers_for_all_existing_users_and_user_template(self, 264 | template_fn 265 | ): 266 | template_fn.return_value = self.template_ls_path 267 | fake_user_home_location = os.path.join(self.tmp, 'Users') 268 | fake_user_homes = create_user_homes(randint(1, 3), fake_user_home_location) 269 | handlers = LSHandlerFactory.build_batch(randint(4, 6)) 270 | arguments = ['set', '-feu', '-fut', handlers[0].app_id] 271 | 272 | for handler in handlers: 273 | if '.' in handler.uti: 274 | if handler.uti == msda.EXTENSION_UTI: 275 | arguments.extend(['-e', handler.extension, handler.role]) 276 | else: 277 | arguments.extend(['-u', handler.uti, handler.role]) 278 | else: 279 | arguments.extend(['-p', handler.uti]) 280 | 281 | for user_home in fake_user_homes: 282 | user_ls_path = self.seed_plist( 283 | SIMPLE_BINARY_PLIST, 284 | os.path.join(user_home, msda.PLIST_RELATIVE_LOCATION), 285 | msda.PLIST_NAME, 286 | ) 287 | user_ls = msda.LaunchServices(user_ls_path) 288 | for handler in handlers: 289 | self.assertNotIn(handler, user_ls.handlers) 290 | self.assertNotIn(handler.app_id, user_ls.app_ids) 291 | 292 | with mock.patch('msda.USER_HOMES_LOCATION', fake_user_home_location): 293 | msda.main(arguments) 294 | 295 | for user_home in fake_user_homes: 296 | user_ls.read() 297 | for handler in handlers: 298 | self.assertIn(handler, user_ls.handlers) 299 | self.assertIn(handler.app_id, user_ls.app_ids) 300 | 301 | def test_set_handlers_for_all_existing_valid_users(self,): 302 | fake_user_home_location = os.path.join(self.tmp, 'Users') 303 | fake_user_homes = create_user_homes(randint(3, 5), fake_user_home_location) 304 | num_invalid_users = randint(1, 2) 305 | handlers = LSHandlerFactory.build_batch(randint(4, 6)) 306 | arguments = ['set', '-feu', handlers[0].app_id] 307 | 308 | for handler in handlers: 309 | if '.' in handler.uti: 310 | if handler.uti == msda.EXTENSION_UTI: 311 | arguments.extend(['-e', handler.extension, handler.role]) 312 | else: 313 | arguments.extend(['-u', handler.uti, handler.role]) 314 | else: 315 | arguments.extend(['-p', handler.uti]) 316 | 317 | for user_home in fake_user_homes[:-num_invalid_users]: 318 | user_ls_path = os.path.join( 319 | user_home, 320 | msda.PLIST_RELATIVE_LOCATION, 321 | msda.PLIST_NAME, 322 | ) 323 | os.makedirs(os.path.dirname(user_ls_path)) 324 | user_ls = msda.LaunchServices(user_ls_path) 325 | for handler in handlers: 326 | self.assertNotIn(handler, user_ls.handlers) 327 | self.assertNotIn(handler.app_id, user_ls.app_ids) 328 | 329 | with mock.patch('msda.USER_HOMES_LOCATION', fake_user_home_location): 330 | msda.main(arguments) 331 | 332 | for user_home in fake_user_homes[:-num_invalid_users]: 333 | user_ls.read() 334 | for handler in handlers: 335 | self.assertIn(handler, user_ls.handlers) 336 | self.assertIn(handler.app_id, user_ls.app_ids) 337 | 338 | for user_home in fake_user_homes[-num_invalid_users:]: 339 | self.assertFalse(os.path.exists(os.path.join( 340 | user_home, 341 | msda.PLIST_RELATIVE_LOCATION, 342 | msda.PLIST_NAME, 343 | ))) 344 | 345 | @mock.patch('msda.create_user_ls_path') 346 | def test_set_single_extension_handler_for_current_user(self, user_fn): 347 | user_fn.return_value = self.user_ls_path 348 | handler = LSHandlerFactory(extension_only=True) 349 | 350 | self.assertNotIn(handler, self.user_ls.handlers) 351 | 352 | arguments = [ 353 | 'set', 354 | handler.app_id, 355 | '-e', handler.extension, 'all', 356 | ] 357 | msda.main(arguments) 358 | 359 | self.user_ls.read() 360 | self.assertIn(handler, self.user_ls.handlers) 361 | 362 | 363 | if __name__ == '__main__': 364 | unittest.main() 365 | -------------------------------------------------------------------------------- /payload/msda.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """\ 4 | Directly modifies launchservices plists to set default file associations in macOS. 5 | 6 | A somewhat complete list of UTI's can be found here: 7 | https://escapetech.eu/manuals/qdrop/uti.html\ 8 | """ 9 | 10 | from __future__ import print_function 11 | 12 | import os, shutil, subprocess, sys, time 13 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 14 | from platform import mac_ver 15 | import plistlib 16 | from tempfile import NamedTemporaryFile 17 | 18 | 19 | ############################################################################### 20 | # 21 | # User-Editable Settings 22 | # 23 | ############################################################################### 24 | 25 | JAMF = False # Is this being used as a Jamf script? 26 | TMP_PREFIX = 'msda_tmp_' # Prefixes tempoaray files created by this app 27 | USER_HOMES_LOCATION = '/Users' # Where users' home directories are located 28 | 29 | 30 | ############################################################################### 31 | # 32 | # App Information 33 | # 34 | ############################################################################### 35 | 36 | __author__ = 'David G. Rosenberg' 37 | __copyright__ = 'Copyright (c), Mac Set Default Apps' 38 | __license__ = 'MIT' 39 | __version__ = '1.3.1' 40 | __email__ = 'dgrosenberg@icloud.com' 41 | 42 | 43 | ############################################################################### 44 | # 45 | # Settings Users Shouldn't Change 46 | # 47 | ############################################################################### 48 | 49 | EXTENSION_UTI = 'public.filename-extension' 50 | LSREGISTER_BINARY = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister' 51 | OS_VERSION = float(mac_ver()[0][3:]) 52 | PLIST_NAME = 'com.apple.launchservices.secure.plist' 53 | PLIST_RELATIVE_LOCATION = 'Library/Preferences/com.apple.LaunchServices/' 54 | USER_TEMPLATE_LOCATION = '/Library/User Template/English.lproj' 55 | 56 | if OS_VERSION < 15.0: 57 | USER_TEMPLATE_LOCATION = os.path.join( 58 | '/System', USER_TEMPLATE_LOCATION 59 | ) 60 | 61 | 62 | ############################################################################### 63 | # 64 | # Functions 65 | # 66 | ############################################################################### 67 | 68 | def create_plist_parents(plist_path): 69 | """ 70 | Creates the directory structure if the provided plist doesn't exist 71 | """ 72 | 73 | # if the specified plist already exists, don't do anything 74 | if os.path.isfile(plist_path): 75 | return False 76 | 77 | # if the specified plist's parent directories already exist, don't do 78 | # anything 79 | parent_path = os.path.dirname(plist_path) 80 | if os.path.exists(parent_path): 81 | return False 82 | 83 | # create the parent directories for the specified plist 84 | os.makedirs(parent_path) 85 | return plist_path 86 | 87 | def create_user_ls_path(username): 88 | path = os.path.join( 89 | USER_HOMES_LOCATION, 90 | username, 91 | PLIST_RELATIVE_LOCATION, 92 | PLIST_NAME, 93 | ) 94 | return path 95 | 96 | def create_template_ls_path(): 97 | path = os.path.join( 98 | USER_TEMPLATE_LOCATION, 99 | PLIST_RELATIVE_LOCATION, 100 | PLIST_NAME, 101 | ) 102 | return path 103 | 104 | def get_current_username(): 105 | with subprocess.Popen(("who"), stdout=subprocess.PIPE) as who_cmd: 106 | with subprocess.Popen( 107 | ("grep", "console"), stdin=who_cmd.stdout, stdout=subprocess.PIPE 108 | ) as grep_cmd: 109 | with subprocess.Popen( 110 | ("cut", "-d", " ", "-f1"), stdin=grep_cmd.stdout, stdout=subprocess.PIPE 111 | ) as username_cmd: 112 | return subprocess.check_output( 113 | ("head", "-n", "1"), stdin=username_cmd.stdout 114 | ).strip().decode("utf-8") 115 | 116 | def gather_user_ls_paths(): 117 | gathered_users = os.listdir(USER_HOMES_LOCATION) 118 | ls_paths = [] 119 | for user in gathered_users: 120 | user_ls_path = create_user_ls_path(user) 121 | if os.path.exists(os.path.dirname(user_ls_path)): 122 | ls_paths.append(user_ls_path) 123 | 124 | return ls_paths 125 | 126 | 127 | ############################################################################### 128 | # 129 | # Class Definitions 130 | # 131 | ############################################################################### 132 | 133 | class LSHandler(object): 134 | 135 | def _from_dict(self, from_dict): 136 | """ 137 | Creates an LSHandler object from a dictionary 138 | """ 139 | 140 | # preset extension 141 | self.extension = None 142 | 143 | # grab the role from the string containing it 144 | self.role = list(from_dict['LSHandlerPreferredVersions'].keys())[0] 145 | self.role = self.role[13:].lower() 146 | 147 | # grab the UTI/protocol/extension 148 | try: 149 | # for if it's a UTI 150 | self.uti = from_dict['LSHandlerContentType'].lower() 151 | self._type = 'ContentType' 152 | except KeyError: 153 | pass 154 | try: 155 | # for if it's an extension 156 | self.uti = from_dict['LSHandlerContentTagClass'].lower() 157 | self._type = 'ContentTagClass' 158 | self.extension = from_dict['LSHandlerContentTag'].lower() 159 | except KeyError: 160 | pass 161 | try: 162 | # for if it's a protocol 163 | self.uti = from_dict['LSHandlerURLScheme'].lower() 164 | self._type = 'URLScheme' 165 | except KeyError: 166 | pass 167 | 168 | # grab the App ID 169 | self.app_id = from_dict[self._role_key].lower() 170 | 171 | def _from_properties(self, **kwargs): 172 | """ 173 | Creates an LSHandler from specified properties 174 | """ 175 | self.app_id = kwargs['app_id'].lower() 176 | self.uti = kwargs['uti'].lower() 177 | self.extension = None 178 | 179 | # determines if we're working with a UTI or protocol based on the 180 | # presence of periods 181 | if '.' in self.uti: 182 | self.role = kwargs.get('role') or 'all' 183 | self.role = self.role.lower() 184 | self._type = 'ContentType' 185 | 186 | # additionally determine if we're working with a file extension 187 | if self.uti == EXTENSION_UTI: 188 | self.extension = kwargs['extension'].lower() 189 | self._type = 'ContentTagClass' 190 | else: 191 | self._type = 'URLScheme' 192 | self.role = 'all' 193 | 194 | def __init__(self, from_dict=None, **kwargs): 195 | if from_dict: 196 | self._from_dict(from_dict) 197 | else: 198 | self._from_properties(**kwargs) 199 | 200 | @property 201 | def _role_key(self): 202 | """ 203 | Generates the dictionary key for an LSHandler's role 204 | """ 205 | return 'LSHandlerRole' + self.role.capitalize() 206 | 207 | def __eq__(self, other): 208 | """ 209 | Two LSHandlers for the same role and UTI would be considered equal 210 | """ 211 | compare_utis = self.uti == other.uti 212 | compare_extensions = self.extension == other.extension 213 | if self.role == 'all' or other.role == 'all': 214 | compare_roles = True 215 | else: 216 | compare_roles = self.role == other.role 217 | return compare_utis and compare_roles and compare_extensions 218 | 219 | def __ne__(self, other): 220 | """ 221 | Inverts the __eq__ function 222 | """ 223 | return not self == other 224 | 225 | def __iter__(self): 226 | yield ('LSHandler' + self._type, self.uti) 227 | yield (self._role_key, self.app_id) 228 | yield ('LSHandlerPreferredVersions', { self._role_key: '-' }) 229 | if self.extension: 230 | yield('LSHandlerContentTag', self.extension) 231 | 232 | 233 | class LaunchServices(object): 234 | 235 | def __init__(self, plist=None): 236 | self.handlers = [] 237 | self.plist = plist 238 | 239 | if self.plist: 240 | self.read() 241 | 242 | def __iter__(self): 243 | yield ('LSHandlers', [ dict(h) for h in self.handlers ]) 244 | 245 | def read(self): 246 | """ 247 | Reads the plist at the specified path into a LaunchServices object, 248 | creating LSHandler objects as necesary 249 | """ 250 | 251 | # is the specified plist doesn't exist, there's nothing to read 252 | if not os.path.isfile(self.plist): 253 | return 254 | 255 | with NamedTemporaryFile(prefix=TMP_PREFIX, delete=True) as tmp_plist: 256 | # copy the target plist to a temporary file 257 | tmp_path = tmp_plist.name 258 | shutil.copyfile(self.plist, tmp_path) 259 | 260 | # convert to XML from binary 261 | convert_command = '/usr/bin/plutil -convert xml1 ' + tmp_path 262 | subprocess.check_output(convert_command.split()) 263 | 264 | # read the plist 265 | with open(tmp_path, "rb") as file: 266 | plist = plistlib.load(file) 267 | 268 | # convert any specified LSHandlers to objects 269 | for lshandler in plist['LSHandlers']: 270 | self.handlers.append(LSHandler(from_dict=lshandler)) 271 | 272 | def write(self, plist=None): 273 | """ 274 | Writes this object to the specified plist, formatting it as a 275 | LaunchServices plist 276 | """ 277 | 278 | # allow for alternate destinations (mostly for testing) 279 | if not plist: 280 | plist = self.plist 281 | 282 | # create parent directories if they don't exist 283 | create_plist_parents(plist) 284 | 285 | with NamedTemporaryFile(prefix=TMP_PREFIX, delete=True) as tmp_plist: 286 | # write the LaunchServices object to a temporary file 287 | tmp_path = tmp_plist.name 288 | with open(tmp_path, "wb") as file: 289 | plistlib.dump(dict(self), file) 290 | 291 | # convert it to binary 292 | convert_command = '/usr/bin/plutil -convert binary1 ' + tmp_path 293 | subprocess.check_output(convert_command.split()) 294 | 295 | # and overwrite the specified plist 296 | shutil.copyfile(tmp_path, plist) 297 | 298 | @property 299 | def app_ids(self): 300 | """ 301 | Provides a set of all App IDs set as default handlers 302 | """ 303 | collected_app_ids = [ h.app_id for h in self.handlers ] 304 | return set(collected_app_ids) 305 | 306 | def set_handler(self, lshandler=None, **kwargs): 307 | """ 308 | Adds the provided LSHandler to the LaunchServices object, converting 309 | to a new LSHandler if necesary 310 | """ 311 | if not lshandler: 312 | new_lshandler = LSHandler(**kwargs) 313 | else: 314 | new_lshandler = lshandler 315 | self.handlers = [ h for h in self.handlers if h != new_lshandler ] 316 | self.handlers.append(new_lshandler) 317 | return new_lshandler 318 | 319 | 320 | ############################################################################### 321 | # 322 | # Main Functions 323 | # 324 | ############################################################################### 325 | 326 | def set_command(args): 327 | # print('Setting "{}" as a default handler in...'.format(args.app_id)) 328 | 329 | # Check for current user 330 | current_username = get_current_username() 331 | 332 | # Collect plists 333 | plists = [] 334 | if args.feu: 335 | plists.extend(gather_user_ls_paths()) 336 | elif current_username != '': 337 | plists.append(create_user_ls_path(current_username)) 338 | if args.fut: 339 | plists.append(create_template_ls_path()) 340 | 341 | # Process plists 342 | for plist in plists: 343 | ls = LaunchServices(plist) 344 | # print(' "{}"...'.format(ls.plist)) 345 | 346 | # Combine submitted UTIs and protocols 347 | if not args.uti: 348 | args.uti = [] 349 | if args.protocol: 350 | args.uti += [ [p, None] for p in args.protocol ] 351 | 352 | # Create and set UTI and protocol handlers 353 | for uti in args.uti: 354 | # if uti[1] != None: 355 | # print(' for "{}" with role "{}"'.format(uti[0], uti[1])) 356 | # else: 357 | # print(' for "{}" with role "all"'.format(uti[0])) 358 | ls.set_handler( 359 | app_id=args.app_id, 360 | uti=uti[0], 361 | role=uti[1], 362 | ) 363 | 364 | # Create and set extension handlers 365 | if args.extension: 366 | for extension in args.extension: 367 | ls.set_handler( 368 | app_id=args.app_id, 369 | uti=EXTENSION_UTI, 370 | role=extension[1], 371 | extension=extension[0], 372 | ) 373 | 374 | ls.write() 375 | return 0 376 | 377 | def main(arguments=None): 378 | if JAMF: 379 | # Strip first 3 args and convert 4th to a list 380 | arguments = arguments[3].split() 381 | 382 | # Global parser setup 383 | parser = ArgumentParser( 384 | description=__doc__, 385 | formatter_class=RawDescriptionHelpFormatter, 386 | epilog='Please email {} with any issues'.format( 387 | __email__, 388 | ) 389 | ) 390 | # parser.add_argument( 391 | # '-v', '--verbose', 392 | # help='verbose output', 393 | # action='store_true', 394 | # ) 395 | parser.add_argument( 396 | '--version', 397 | help='prints the current version', 398 | action='version', 399 | version=__version__, 400 | ) 401 | subparsers = parser.add_subparsers( 402 | help='the subcommand to run', 403 | metavar='command', 404 | dest='command', 405 | ) 406 | 407 | # "set" parser setup 408 | set_parser = subparsers.add_parser( 409 | 'set', 410 | help='set LSHandlers for a given App ID', 411 | ) 412 | set_parser.set_defaults(func=set_command) 413 | set_parser.add_argument( 414 | 'app_id', 415 | help='the identifier of the application to set as a default', 416 | type=str, 417 | ) 418 | set_parser.add_argument( 419 | '-feu', 420 | help='updates all existing users\' launch services', 421 | action='store_true', 422 | ) 423 | set_parser.add_argument( 424 | '-fut', 425 | help='updates the user template\'s launch services', 426 | action='store_true', 427 | ) 428 | set_parser.add_argument( 429 | '-e', '--extension', 430 | help='file extensions to associate with the given app ID', 431 | action='append', 432 | nargs=2, 433 | metavar=('EXTENSION', 'ROLE'), 434 | ) 435 | set_parser.add_argument( 436 | '-p', '--protocol', 437 | help='protocols to associate with the given app ID', 438 | action='append', 439 | ) 440 | set_parser.add_argument( 441 | '-u', '--uti', 442 | help='UTIs and roles to associate with the given app ID', 443 | action='append', 444 | nargs=2, 445 | metavar=('UTI', 'ROLE'), 446 | ) 447 | 448 | # Process specified args 449 | args = parser.parse_args(arguments) 450 | # global verbose 451 | # verbose = args.verbose 452 | 453 | # print('') 454 | 455 | # Run specified function with processed args 456 | return args.func(args) 457 | 458 | 459 | if __name__ == '__main__': 460 | # Determine whether to run as a user or as root 461 | username = get_current_username() 462 | if username in ['', 'root', '_mbsetupuser']: 463 | sudo_command = '/usr/bin/sudo -u root' 464 | domains = 'local,system' 465 | else: 466 | sudo_command = '/usr/bin/sudo -u ' + username 467 | domains = 'user,local,system' 468 | 469 | # Do the thing 470 | exit_code = main(sys.argv[1:]) 471 | 472 | # Kill any running launchservice processes 473 | kill_command = '/usr/bin/killall lsd' 474 | subprocess.check_output(sudo_command.split() + kill_command.split()) 475 | 476 | # Rebuild the launchservices database 477 | rebuild_command = [LSREGISTER_BINARY, 478 | '-kill', '-r', '-f', 479 | '-all', domains, 480 | ] 481 | subprocess.check_output(sudo_command.split() + rebuild_command) 482 | 483 | sys.exit(exit_code) 484 | --------------------------------------------------------------------------------