├── .editorconfig ├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── utiluti.xcscheme ├── LICENSE ├── Package.resolved ├── Package.swift ├── ReadMe.md ├── Sources └── utiluti │ ├── AppCommands.swift │ ├── FileCommands.swift │ ├── GetUTI.swift │ ├── LSKit.swift │ ├── TypeCommands.swift │ ├── URLScheme.swift │ └── utiluti.swift ├── Tests └── utilutiTests │ └── utilutiTests.swift └── pkgAndNotarize.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # Unix-style newlines and whitespace cleanup 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/utiluti.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Armin Briegel, Scripting OS X 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "2fb0a7cc92f4d5e14f8d8f177a2031686d455c4669347152d8bd7d08ea3af71b", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser", 8 | "state" : { 9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 10 | "version" : "1.5.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "utiluti", 8 | platforms: [ 9 | .macOS(.v11) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .executable( 14 | name: "utiluti", 15 | targets: ["utiluti"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package, defining a module or a test suite. 22 | // Targets can depend on other targets in this package and products from dependencies. 23 | .executableTarget( 24 | name: "utiluti", 25 | dependencies: [ 26 | .product( 27 | name: "ArgumentParser", 28 | package: "swift-argument-parser" 29 | )], 30 | path: "Sources/utiluti" 31 | ) 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # utiluti 2 | 3 | macOS command line utility to work with default apps. 4 | 5 | ## What it does 6 | 7 | You can use `utiluti` to inspect and modify default apps for url schemes and file types/uniform type identifiers (UTI). 8 | 9 | ## Important notes: 10 | 11 | - `utiluti` should be run as the current user 12 | 13 | - when you attempt to set the default app for the `http` url scheme, macOS will prompt the user for confirmation. The user has the option to reject the change. The user must make a selection for the tool to continue. Consider this when using `utiluti` for automation. 14 | 15 | - macOS connects the `http` and `https` url schemes and the `public.html` UTI. You can only set the default app for `http` and the default app for `https` and the `public.html` type will be set to the same app. Attempting to change the default apps for `https` or `public.html` will result in an error. 16 | 17 | ## URL schemes 18 | 19 | URL schemes are the part of the URL before the colon `:` which identify which app or protocol to use. E.g. `http`, `mailto`, `ssh`, etc. 20 | 21 | Get the current default app for a given url scheme: 22 | 23 | ```sh 24 | $ utiluti url mailto 25 | /System/Applications/Mail.app 26 | ``` 27 | 28 | Use the `--bundle-id` flag to receive the app's bundle identifier instead: 29 | 30 | ```sh 31 | $ utiluti url mailto --bundle-id 32 | com.apple.mail 33 | ``` 34 | 35 | List all apps registered for a given url scheme: 36 | 37 | ```sh 38 | $ utiluti url list mailto 39 | /System/Applications/Mail.app 40 | /Applications/Microsoft Outlook.app 41 | ``` 42 | 43 | Use the `--bundle-id` flag to receive the apps' bundle identifiers instead: 44 | 45 | ```sh 46 | $ utiluti url list mailto --bundle-id 47 | com.apple.mail 48 | com.microsoft.Outlook 49 | ``` 50 | 51 | Set the default app for a given URL scheme: 52 | 53 | ```sh 54 | $ utiluti url set mailto com.microsoft.Outlook 55 | set com.microsoft.Outlook for mailto 56 | ``` 57 | 58 | ## File Type/Uniform Type Identifiers (UTI) 59 | 60 | [Uniform type identifiers](https://developer.apple.com/documentation/uniformtypeidentifiers/) (UTI) are how macOS maps file and mime type. `utiluti` uses UTIs. 61 | 62 | To get the UTI associated with a file extension, use `get-uti`: 63 | 64 | ```sh 65 | $ utiluti get-uti txt 66 | public.plain-text 67 | ``` 68 | 69 | Get the default application for a UTI: 70 | 71 | ```sh 72 | $ utiluti type public.plain-text 73 | /System/Applications/TextEdit.app 74 | ``` 75 | 76 | List all applications registered for the given UTI: 77 | 78 | ```sh 79 | $ utiluti type list public.plain-text 80 | /System/Applications/TextEdit.app 81 | /Applications/Numbers.app 82 | /Applications/Pages.app 83 | /System/Applications/Utilities/Script Editor.app 84 | /System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app 85 | /Applications/Xcode.app/Contents/Applications/Instruments.app 86 | /Applications/Xcode.app 87 | /System/Applications/Notes.app 88 | ``` 89 | 90 | Add the `--bundle-id` flag to receive bundle identifiers instead of paths. 91 | 92 | Set the the default app for a given UTI: 93 | 94 | ```sh 95 | $ utiluti type set public.plain-text com.barebones.bbedit 96 | set com.barebones.bbedit for public.plain-text 97 | ``` 98 | 99 | -------------------------------------------------------------------------------- /Sources/utiluti/AppCommands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCommands.swift 3 | // utiluti 4 | // 5 | // Created by Armin on 20/03/2025. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | import UniformTypeIdentifiers 11 | import AppKit // for NSWorkspace 12 | 13 | struct AppCommands: ParsableCommand { 14 | static let configuration = CommandConfiguration( 15 | commandName: "app", 16 | abstract: "list uniform types identifiers and url schemes associated with an app", 17 | subcommands: [ Types.self, Schemes.self ] 18 | ) 19 | 20 | struct Types: ParsableCommand { 21 | static let configuration 22 | = CommandConfiguration(abstract: "List the uniform type identifiers this app can open") 23 | 24 | @Argument(help:ArgumentHelp("the app identifier", valueName: "app-identifier")) 25 | var appID: String 26 | 27 | @Flag(name: .shortAndLong, 28 | help: "show more information") 29 | var verbose: Bool = false 30 | 31 | func run() { 32 | guard 33 | let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appID), 34 | let appBundle = Bundle(url: appURL), 35 | let infoDictionary = appBundle.infoDictionary, 36 | let docTypes: [[String: Any]] = infoDictionary["CFBundleDocumentTypes"] as? [[String: Any]] 37 | else { 38 | Self.exit(withError: ExitCode(7)) 39 | } 40 | 41 | for docType in docTypes { 42 | guard 43 | let name = docType["CFBundleTypeName"] as? String 44 | else { continue } 45 | 46 | let types = docType["LSItemContentTypes"] as? [String] ?? [] 47 | let extensions = docType["CFBundleTypeExtensions"] as? [String] ?? [] 48 | 49 | for type in types { 50 | if verbose { 51 | print("\(type) - \(name)") 52 | } else { 53 | print(type) 54 | } 55 | } 56 | 57 | for ext in extensions { 58 | guard let utype = UTType(filenameExtension: ext) else { 59 | Self.exit(withError: ExitCode(3)) 60 | } 61 | 62 | if types.contains(utype.identifier) { 63 | continue 64 | } 65 | 66 | print("file extension: \(ext)", terminator: "") 67 | 68 | if !utype.isDynamic { 69 | print(" (\(utype.identifier))", terminator: "") 70 | } 71 | 72 | if verbose { 73 | print(" - \(name)") 74 | } else { 75 | print() 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | struct Schemes: ParsableCommand { 83 | static let configuration 84 | = CommandConfiguration(abstract: "List the urls schemes this app can open") 85 | 86 | @Argument(help:ArgumentHelp("the app identifier", valueName: "app-identifier")) 87 | var appID: String 88 | 89 | @Flag(name: .shortAndLong, 90 | help: "show more information") 91 | var verbose: Bool = false 92 | 93 | func run() { 94 | guard 95 | let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appID), 96 | let appBundle = Bundle(url: appURL), 97 | let infoDictionary = appBundle.infoDictionary, 98 | let urlSchemes: [[String: Any]] = infoDictionary["CFBundleURLTypes"] as? [[String: Any]] 99 | else { 100 | Self.exit(withError: ExitCode(7)) 101 | } 102 | 103 | for docType in urlSchemes { 104 | guard 105 | let name = docType["CFBundleURLName"] as? String, 106 | let schemes = docType["CFBundleURLSchemes"] as? [String] 107 | else { continue } 108 | for type in schemes { 109 | if verbose { 110 | print("\(type) - \(name)") 111 | } else { 112 | print(type) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/utiluti/FileCommands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileCommands.swift 3 | // utiluti 4 | // 5 | // Created by Armin on 25/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | import Foundation 11 | import ArgumentParser 12 | import UniformTypeIdentifiers 13 | import AppKit // for NSWorkspace 14 | 15 | struct FileCommands: ParsableCommand { 16 | 17 | static var subCommands: [ParsableCommand.Type] { 18 | if #available(macOS 12.0, *) { 19 | return [ GetUTI.self, App.self, ListApps.self, Set.self ] 20 | } else { 21 | return [ GetUTI.self, App.self ] 22 | } 23 | } 24 | 25 | static let configuration = CommandConfiguration( 26 | commandName: "file", 27 | abstract: "commands to manage specific files", 28 | subcommands: subCommands 29 | ) 30 | 31 | struct GetUTI: ParsableCommand { 32 | static let configuration 33 | = CommandConfiguration(abstract: "get the uniform type identifier of a file") 34 | 35 | @Argument(help:ArgumentHelp("file path", valueName: "path")) 36 | var path: String 37 | 38 | func run() { 39 | let url = URL(fileURLWithPath: path) 40 | let typeIdentifier = try? url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier 41 | print(typeIdentifier ?? "") 42 | } 43 | } 44 | 45 | struct App: ParsableCommand { 46 | static let configuration 47 | = CommandConfiguration(abstract: "get the app that will open this file") 48 | 49 | @Argument(help:ArgumentHelp("file path", valueName: "path")) 50 | var path: String 51 | 52 | @Flag(help: ArgumentHelp( 53 | "list bundle identifiers instead of paths", 54 | valueName: "bundleID")) 55 | var bundleID = false 56 | 57 | func run() { 58 | let url = URL(fileURLWithPath: path) 59 | if let app = NSWorkspace.shared.urlForApplication(toOpen: url) { 60 | if bundleID { 61 | guard let appBundle = Bundle(url: app) else { 62 | Self.exit(withError: ExitCode(6)) 63 | } 64 | print(appBundle.bundleIdentifier ?? "") 65 | } else { 66 | print(app.path) 67 | } 68 | } else { 69 | print("no app found") 70 | Self.exit(withError: ExitCode(9)) 71 | } 72 | } 73 | } 74 | 75 | @available(macOS 12, *) 76 | struct ListApps: ParsableCommand { 77 | static let configuration 78 | = CommandConfiguration(abstract: "get all app that can open this file") 79 | 80 | @Argument(help:ArgumentHelp("file path", valueName: "path")) 81 | var path: String 82 | 83 | @Flag(help: ArgumentHelp( 84 | "list bundle identifiers instead of paths", 85 | valueName: "bundleID")) 86 | var bundleID = false 87 | 88 | func run() { 89 | let url = URL(fileURLWithPath: path) 90 | let apps = NSWorkspace.shared.urlsForApplications(toOpen: url) 91 | for app in apps { 92 | if bundleID { 93 | guard let appBundle = Bundle(url: app) else { 94 | Self.exit(withError: ExitCode(6)) 95 | } 96 | print(appBundle.bundleIdentifier ?? "") 97 | } else { 98 | print(app.path) 99 | } 100 | } 101 | } 102 | } 103 | 104 | @available(macOS 12, *) 105 | struct Set: AsyncParsableCommand { 106 | static let configuration 107 | = CommandConfiguration(abstract: "Set the default app for this specific file") 108 | 109 | @Argument(help:ArgumentHelp("file path", valueName: "path")) 110 | var path: String 111 | 112 | @Argument(help: "the bundle identifier for the new default app") 113 | var identifier: String 114 | 115 | func run() async throws { 116 | let url = URL(fileURLWithPath: path) 117 | guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) 118 | else { 119 | Self.exit(withError: ExitCode(11)) 120 | } 121 | try await NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenFileAt: url) 122 | print("set \(identifier) for \(path)") 123 | } 124 | } 125 | } 126 | 127 | -------------------------------------------------------------------------------- /Sources/utiluti/GetUTI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetIdentifier.swift 3 | // utiluti 4 | // 5 | // Created by Armin Briegel on 2022-11-10. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | import UniformTypeIdentifiers 11 | 12 | @available(macOS 11.0, *) 13 | struct GetUTI: ParsableCommand { 14 | static let configuration 15 | = CommandConfiguration(abstract: "Get the type identifier (UTI) for a file extension") 16 | 17 | @Argument(help: "file extension") 18 | var fileExtension: String 19 | 20 | @Flag(help: "show dynamic identifiers") 21 | var showDynamic = false 22 | 23 | func run() { 24 | guard let utype = UTType(filenameExtension: fileExtension) else { 25 | Self.exit(withError: ExitCode(3)) 26 | } 27 | 28 | if utype.identifier.hasPrefix("dyn.") { 29 | if showDynamic { 30 | print(utype.identifier) 31 | } 32 | } else { 33 | print(utype.identifier) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/utiluti/LSKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LSKit.swift 3 | // LSKit 4 | // 5 | // Created by Armin Briegel on 2021-08-30. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import UniformTypeIdentifiers 11 | 12 | struct LSKit { 13 | 14 | /** 15 | returns a list of URLs to applications that can open URLs starting with the scheme 16 | - Parameter scheme: url scheme (excluding the `:` or `/`, e.g. `http`) 17 | - Returns: array of app URLs 18 | */ 19 | static func appURLs(forScheme scheme: String) -> [URL] { 20 | guard let url = URL(string: "\(scheme):") else { return [URL]() } 21 | 22 | if #available(macOS 12, *) { 23 | //print("running on macOS 12, using NSWorkspace") 24 | let ws = NSWorkspace.shared 25 | return ws.urlsForApplications(toOpen: url) 26 | } else { 27 | var urlList = [URL]() 28 | if let result = LSCopyApplicationURLsForURL(url as CFURL, .all) { 29 | let cfURLList = result.takeRetainedValue() as Array 30 | for item in cfURLList { 31 | if let appURL = item as? URL { 32 | urlList.append(appURL) 33 | } 34 | } 35 | } 36 | return urlList 37 | } 38 | } 39 | 40 | /** 41 | returns URL to the default application for URLs starting with scheme 42 | - Parameter scheme: url scheme (excluding the `:` or `/`, e.g. `http`) 43 | - Returns: urls to default application 44 | */ 45 | static func defaultAppURL(forScheme scheme: String) -> URL? { 46 | guard let url = URL(string: "\(scheme):") else { return nil } 47 | 48 | if #available(macOS 12, *) { 49 | //print("running on macOS 12, using NSWorkspace") 50 | let ws = NSWorkspace.shared 51 | return ws.urlForApplication(toOpen: url) 52 | } else { 53 | if let result = LSCopyDefaultApplicationURLForURL(url as CFURL, .all, nil) { 54 | let appURL = result.takeRetainedValue() as URL 55 | return appURL 56 | } 57 | return nil 58 | } 59 | } 60 | 61 | /** 62 | set the default app for scheme to the app with the identifier 63 | - Parameters: 64 | - identifier: bundle id of the new default application 65 | - scheme: url scheme (excluding the `:` or `/`, e.g. `http`) 66 | - Returns: OSStatus (discardable) 67 | */ 68 | @discardableResult static func setDefaultApp(identifier: String, forScheme scheme: String) -> OSStatus { 69 | if #available(macOS 12, *) { 70 | // print("running on macOS 12, using NSWorkspace") 71 | let ws = NSWorkspace.shared 72 | 73 | // since the new NSWorkspace function is asynchronous we have to use semaphores here 74 | let semaphore = DispatchSemaphore(value: 0) 75 | var errCode: OSStatus = 0 76 | 77 | guard let appURL = ws.urlForApplication(withBundleIdentifier: identifier) else { return 1 } 78 | ws.setDefaultApplication(at: appURL, toOpenURLsWithScheme: scheme) { err in 79 | // err is an NSError wrapped in a CocoaError 80 | if let err = err as? CocoaError { 81 | if let underlyingError = err.errorUserInfo["NSUnderlyingError"] as? NSError { 82 | errCode = OSStatus(clamping: underlyingError.code) 83 | } 84 | } 85 | semaphore.signal() 86 | } 87 | semaphore.wait() 88 | return errCode 89 | } else { 90 | return LSSetDefaultHandlerForURLScheme(scheme as CFString, identifier as CFString) 91 | } 92 | } 93 | 94 | /** 95 | returns a list of URLs to applications that can open the given type identifier 96 | - Parameter forTypeIdentifier: Uniform Type Identifier, e.g. `public.html` 97 | - Returns: array of URLs to apps 98 | */ 99 | static func appURLs(forTypeIdentifier utidentifier: String) -> [URL] { 100 | if #available(macOS 12, *) { 101 | //print("running on macOS 12, using NSWorkspace") 102 | let ws = NSWorkspace.shared 103 | guard let utype = UTType(utidentifier) else { 104 | return [URL]() 105 | } 106 | return ws.urlsForApplications(toOpen: utype) 107 | } else { 108 | var urlList = [URL]() 109 | if let result = LSCopyAllRoleHandlersForContentType(utidentifier as CFString, .all) { 110 | let cfURLList = result.takeRetainedValue() as Array 111 | for item in cfURLList { 112 | if let appURL = item as? URL { 113 | urlList.append(appURL) 114 | } 115 | } 116 | } 117 | return urlList 118 | } 119 | } 120 | 121 | /** 122 | returns URL to the default application for the given type identifier 123 | - Parameter forTypeIdentifier: url scheme (excluding the `:` or `/`, e.g. `http`) 124 | - Returns: url to default application 125 | */ 126 | static func defaultAppURL(forTypeIdentifier utidentifier: String) -> URL? { 127 | if #available(macOS 12, *) { 128 | //print("running on macOS 12, using NSWorkspace") 129 | guard let utype = UTType(utidentifier) else { 130 | return nil 131 | } 132 | let ws = NSWorkspace.shared 133 | return ws.urlForApplication(toOpen: utype) 134 | } else { 135 | if let result = LSCopyDefaultApplicationURLForContentType(utidentifier as CFString, .all, nil) { 136 | let appURL = result.takeRetainedValue() as URL 137 | return appURL 138 | } 139 | return nil 140 | } 141 | } 142 | 143 | /** 144 | set the default app for type identifier to the app with the bundle indentifier 145 | - Parameters: 146 | - identifier: bundle id of the new default application 147 | - forTypeIdentifier: uniform type identifier ( e.g. `public.html`) 148 | - Returns: OSStatus (discardable) 149 | */ 150 | @discardableResult static func setDefaultApp(identifier: String, forTypeIdentifier utidentifier: String) -> OSStatus { 151 | if #available(macOS 12, *) { 152 | // print("running on macOS 12, using NSWorkspace") 153 | guard let utype = UTType(utidentifier) else { 154 | return 1 155 | } 156 | 157 | let ws = NSWorkspace.shared 158 | 159 | // since the new NSWorkspace function is asynchronous we have to use semaphores here 160 | let semaphore = DispatchSemaphore(value: 0) 161 | var errCode: OSStatus = 0 162 | 163 | guard let appURL = ws.urlForApplication(withBundleIdentifier: identifier) else { return 1 } 164 | ws.setDefaultApplication(at: appURL, toOpen: utype) { err in 165 | // err is an NSError wrapped in a CocoaError 166 | if let err = err as? CocoaError { 167 | if let underlyingError = err.errorUserInfo["NSUnderlyingError"] as? NSError { 168 | errCode = OSStatus(clamping: underlyingError.code) 169 | } 170 | } 171 | semaphore.signal() 172 | } 173 | semaphore.wait() 174 | return errCode 175 | } else { 176 | return LSSetDefaultRoleHandlerForContentType(utidentifier as CFString, .all, identifier as CFString) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /Sources/utiluti/TypeCommands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeCommands.swift 3 | // utiluti 4 | // 5 | // Created by Armin Briegel on 2022-11-10. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | import UniformTypeIdentifiers 11 | 12 | struct TypeCommands: ParsableCommand { 13 | 14 | static var subCommands: [ParsableCommand.Type] { 15 | if #available(macOS 11.0, *) { 16 | return [Get.self, List.self, Set.self, Info.self, FileExtensions.self] 17 | } else { 18 | return[Get.self, List.self, Set.self] 19 | } 20 | } 21 | 22 | static let configuration = CommandConfiguration( 23 | commandName: "type", 24 | abstract: "Manipulate default file type handlers", 25 | subcommands: subCommands, 26 | defaultSubcommand: Get.self 27 | ) 28 | 29 | struct UTIdentifier: ParsableArguments { 30 | @Argument(help: ArgumentHelp( 31 | "universal type identifier, e.g. 'public.html'", 32 | valueName: "uti")) 33 | var value: String 34 | } 35 | 36 | struct IdentifierFlag: ParsableArguments { 37 | @Flag(help: ArgumentHelp( 38 | "list bundle identifiers instead of paths", 39 | valueName: "bundleID")) 40 | var bundleID = false 41 | } 42 | 43 | struct Get: ParsableCommand { 44 | static let configuration 45 | = CommandConfiguration(abstract: "Get the path to the default application.") 46 | 47 | @OptionGroup var utidentifier: UTIdentifier 48 | @OptionGroup var bundleID: IdentifierFlag 49 | 50 | func run() { 51 | guard let appURL = LSKit.defaultAppURL(forTypeIdentifier: utidentifier.value) else { 52 | print("") 53 | return 54 | } 55 | if bundleID.bundleID { 56 | guard let appBundle = Bundle(url: appURL) else { 57 | Self.exit(withError: ExitCode(6)) 58 | } 59 | print(appBundle.bundleIdentifier ?? "") 60 | } else { 61 | print(appURL.path) 62 | } 63 | } 64 | } 65 | 66 | struct List: ParsableCommand { 67 | static let configuration 68 | = CommandConfiguration(abstract: "List all applications that can handle this type identifier.") 69 | 70 | @OptionGroup var utidentifier: UTIdentifier 71 | @OptionGroup var bundleID: IdentifierFlag 72 | 73 | func run() { 74 | let appURLs = LSKit.appURLs(forTypeIdentifier: utidentifier.value) 75 | 76 | for appURL in appURLs { 77 | if bundleID.bundleID { 78 | if let appBundle = Bundle(url: appURL) { 79 | print(appBundle.bundleIdentifier ?? "") 80 | } else { 81 | print("<'\(appURL.path)' is not a bundle>") 82 | } 83 | } else { 84 | print(appURL.path) 85 | } 86 | } 87 | } 88 | } 89 | 90 | struct Set: ParsableCommand { 91 | static let configuration 92 | = CommandConfiguration(abstract: "Set the default app for this type identifier.") 93 | 94 | @OptionGroup var utidentifier: UTIdentifier 95 | @Argument var identifier: String 96 | 97 | func run() { 98 | let result = LSKit.setDefaultApp(identifier: identifier, forTypeIdentifier: utidentifier.value) 99 | 100 | if result == 0 { 101 | print("set \(identifier) for \(utidentifier.value)") 102 | } else { 103 | print("cannot set default app for \(utidentifier.value) (error \(result))") 104 | TypeCommands.exit(withError: ExitCode(result)) 105 | } 106 | } 107 | } 108 | 109 | @available(macOS 11.0, *) 110 | struct FileExtensions: ParsableCommand { 111 | static let configuration 112 | = CommandConfiguration(abstract: "prints the file extensions for the given type identifier") 113 | 114 | @OptionGroup var utidentifier: UTIdentifier 115 | 116 | func run() { 117 | guard let utype = UTType(utidentifier.value) else { 118 | print("") 119 | TypeCommands.exit(withError: ExitCode(3)) 120 | } 121 | 122 | let extensions = utype.tags[.filenameExtension] ?? [] 123 | print(extensions.joined(separator: " ")) 124 | } 125 | } 126 | 127 | @available(macOS 11.0, *) 128 | struct Info: ParsableCommand { 129 | static let configuration 130 | = CommandConfiguration(abstract: "prints information for the given type identifier") 131 | 132 | @OptionGroup var utidentifier: UTIdentifier 133 | 134 | func run() { 135 | guard let utype = UTType(utidentifier.value) else { 136 | print("") 137 | TypeCommands.exit(withError: ExitCode(3)) 138 | } 139 | 140 | for (key, value) in utype.tags { 141 | print("\(key): \(value)") 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/utiluti/URLScheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLScheme.swift 3 | // utiluti 4 | // 5 | // Created by Armin Briegel on 2022-11-10. 6 | // 7 | 8 | import Foundation 9 | import ArgumentParser 10 | 11 | struct URLCommands: ParsableCommand { 12 | static let configuration = CommandConfiguration( 13 | commandName: "url", 14 | abstract: "Manipulate default URL scheme handlers", 15 | subcommands: [ Get.self, List.self, Set.self], 16 | defaultSubcommand: Get.self) 17 | 18 | struct URLScheme: ParsableArguments { 19 | @Argument(help: ArgumentHelp( 20 | "the url scheme, e.g. 'http' or 'mailto'", 21 | valueName: "scheme")) 22 | var value: String 23 | } 24 | 25 | struct IdentifierFlag: ParsableArguments { 26 | @Flag(help: ArgumentHelp( 27 | "list bundle identifiers instead of paths", 28 | valueName: "bundleID")) 29 | var bundleID = false 30 | } 31 | 32 | struct Get: ParsableCommand { 33 | static let configuration 34 | = CommandConfiguration(abstract: "Get the path to the default application.") 35 | 36 | @OptionGroup var scheme: URLScheme 37 | @OptionGroup var bundleID: IdentifierFlag 38 | 39 | func run() { 40 | guard let appURL = LSKit.defaultAppURL(forScheme: scheme.value) else { 41 | print("") 42 | Self.exit(withError: ExitCode(1)) 43 | } 44 | if bundleID.bundleID { 45 | guard let appBundle = Bundle(url: appURL) else { 46 | Self.exit(withError: ExitCode(6)) 47 | } 48 | print(appBundle.bundleIdentifier ?? "") 49 | } else { 50 | print(appURL.path) 51 | } 52 | } 53 | } 54 | 55 | struct List: ParsableCommand { 56 | static let configuration 57 | = CommandConfiguration(abstract: "List all applications that can handle this URL scheme.") 58 | 59 | @OptionGroup var scheme: URLScheme 60 | @OptionGroup var bundleID: IdentifierFlag 61 | 62 | func run() { 63 | let appURLs = LSKit.appURLs(forScheme: scheme.value) 64 | 65 | for appURL in appURLs { 66 | if bundleID.bundleID { 67 | if let appBundle = Bundle(url: appURL) { 68 | print(appBundle.bundleIdentifier ?? "") 69 | } else { 70 | print("<'\(appURL.path)' is not a bundle>") 71 | } 72 | } else { 73 | print(appURL.path) 74 | } 75 | } 76 | } 77 | } 78 | 79 | struct Set: ParsableCommand { 80 | static let configuration 81 | = CommandConfiguration(abstract: "Set the default app for this URL scheme.") 82 | 83 | @OptionGroup var scheme: URLScheme 84 | @Argument(help: ArgumentHelp("bundle identifier for the app", 85 | valueName: "bundleID")) 86 | var identifier: String 87 | 88 | func run() { 89 | let result = LSKit.setDefaultApp(identifier: identifier, forScheme: scheme.value) 90 | 91 | if result == 0 { 92 | print("set \(identifier) for \(scheme.value)") 93 | } else { 94 | print("cannot set default app for \(scheme.value) (error \(result))") 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/utiluti/utiluti.swift: -------------------------------------------------------------------------------- 1 | // The Swift Programming Language 2 | // https://docs.swift.org/swift-book 3 | 4 | import Foundation 5 | import ArgumentParser 6 | 7 | @main 8 | struct UtilUTI: AsyncParsableCommand { 9 | static let subCommands: [ParsableCommand.Type] = [URLCommands.self, TypeCommands.self, GetUTI.self, AppCommands.self, FileCommands.self] 10 | 11 | static let configuration = CommandConfiguration( 12 | commandName: "utiluti", 13 | abstract: "Read and set default URL scheme and file type handlers.", 14 | version: "1.1dev", 15 | subcommands: subCommands 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /Tests/utilutiTests/utilutiTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import utiluti 3 | 4 | @Test func example() async throws { 5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 | } 7 | -------------------------------------------------------------------------------- /pkgAndNotarize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # pkgAndNotarize.sh 4 | 5 | # this script will 6 | # - build the swift package project executable 7 | # - sign the binary 8 | # - create a signed pkg installer file 9 | # - submit the pkg for notarization 10 | # - staple the pkg 11 | 12 | # more detail here: 13 | # https://scriptingosx.com/2023/08/build-a-notarized-package-with-a-swift-package-manager-executable/ 14 | 15 | # by Armin Briegel - Scripting OS X 16 | 17 | # Permission is granted to use this code in any way you want. 18 | # Credit would be nice, but not obligatory. 19 | # Provided "as is", without warranty of any kind, express or implied. 20 | 21 | 22 | # modify these variables for your project 23 | 24 | # Developer ID Installer cert name 25 | developer_name_and_id="Armin Briegel (JME5BW3F3R)" 26 | installer_sign_cert="Developer ID Installer: ${developer_name_and_id}" 27 | application_sign_cert="Developer ID Application: ${developer_name_and_id}" 28 | 29 | # profile name used with `notarytool --store-credentials` 30 | credential_profile="notary-scriptingosx" 31 | 32 | # build info 33 | product_name="utiluti" 34 | binary_names=( "utiluti" ) 35 | 36 | # pkg info 37 | pkg_name="$product_name" 38 | identifier="com.scriptingosx.${product_name}" 39 | min_os_version="11.5" 40 | install_location="/usr/local/bin/" 41 | 42 | 43 | # don't modify below here 44 | 45 | 46 | # calculated variables 47 | SRCROOT=$(dirname ${0:A}) 48 | build_dir="$SRCROOT/.build" 49 | 50 | date +"%F %T" 51 | 52 | # build the binary 53 | 54 | #swift package clean 55 | echo 56 | echo "### building $product_name" 57 | if ! swift build --configuration release \ 58 | --arch arm64 --arch x86_64 59 | then 60 | echo "error building binary" 61 | exit 2 62 | fi 63 | 64 | if [[ ! -d $build_dir ]]; then 65 | echo "couldn't find .build directory" 66 | exit 3 67 | fi 68 | 69 | 70 | binary_source_path="${build_dir}/apple/Products/Release/${binary_names[1]}" 71 | 72 | if [[ ! -e $binary_source_path ]]; then 73 | echo "cannot find binary at $binary_source_path" 74 | exit 4 75 | fi 76 | 77 | # get version from binary 78 | version=$($binary_source_path --version) 79 | 80 | if [[ $version == "" ]]; then 81 | echo "could not get version" 82 | exit 5 83 | fi 84 | 85 | component_path="${build_dir}/${pkg_name}.pkg" 86 | product_path="${build_dir}/${pkg_name}-${version}.pkg" 87 | pkgroot="${build_dir}/pkgroot" 88 | 89 | echo 90 | echo "### Signing, Packaging and Notarizing '$product_name'" 91 | echo "Version: $version" 92 | echo "Identifier: $identifier" 93 | echo "Min OS Version: $min_os_version" 94 | echo "Developer ID: $developer_name_and_id" 95 | 96 | pkgroot="$build_dir/pkgroot" 97 | if [[ ! -d $pkgroot ]]; then 98 | mkdir -p $pkgroot 99 | fi 100 | 101 | for binary in ${binary_names}; do 102 | binary_source_path="${build_dir}/apple/Products/Release/${binary}" 103 | 104 | if [[ ! -f $binary_source_path ]]; then 105 | echo "can't find binary at $binary_path" 106 | exit 6 107 | fi 108 | 109 | cp $binary_source_path $pkgroot 110 | #scripts_dir="$SRCROOT/scripts" 111 | 112 | binary_path=$pkgroot/$binary 113 | 114 | # sign the binary 115 | echo 116 | echo "### signing '${binary}'" 117 | if ! codesign --sign $application_sign_cert \ 118 | --options runtime \ 119 | --runtime-version $min_os_version \ 120 | --timestamp \ 121 | $binary_path 122 | then 123 | echo "error signing binary '${binary}'" 124 | exit 7 125 | fi 126 | 127 | done 128 | 129 | # create the component pkg 130 | echo 131 | echo "### building component pkg file" 132 | 133 | if ! pkgbuild --root $pkgroot \ 134 | --identifier $identifier \ 135 | --version $version-$build_number \ 136 | --install-location $install_location \ 137 | --min-os-version $min_os_version \ 138 | --compression latest \ 139 | $component_path 140 | 141 | # --scripts "$scripts_dir" \ 142 | then 143 | echo "error building component" 144 | exit 8 145 | fi 146 | 147 | # create the distribution pkg 148 | echo 149 | echo "### building distribution pkg file" 150 | 151 | if ! productbuild --package "$component_path" \ 152 | --identifier "$identifier" \ 153 | --version "$version-$build_number" \ 154 | --sign "$installer_sign_cert" \ 155 | "$product_path" 156 | then 157 | echo "error building distribution archive" 158 | exit 9 159 | fi 160 | 161 | # notarize 162 | echo 163 | echo "### submitting for notarization" 164 | if ! xcrun notarytool submit "$product_path" \ 165 | --keychain-profile "$credential_profile" \ 166 | --wait 167 | then 168 | echo "error notarizing pkg" 169 | echo "use 'xcrun notarylog --keychain-profile \"$credential_profile\"' for more detail" 170 | exit 10 171 | fi 172 | 173 | # staple 174 | echo 175 | echo "### staple" 176 | if ! xcrun stapler staple "$product_path" 177 | then 178 | echo "error stapling pkg" 179 | exit 11 180 | fi 181 | 182 | # clean up component pkg 183 | rm "$component_path" 184 | 185 | # clean up pkgroot 186 | rm -rf $pkgroot 187 | 188 | echo 189 | # show result path 190 | echo "### complete" 191 | echo "$product_path" 192 | 193 | exit 0 194 | --------------------------------------------------------------------------------