├── .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 |
--------------------------------------------------------------------------------