├── .gitignore
├── Tests
├── LinuxMain.swift
└── airdrop-cli-tests
│ ├── XCTestManifests.swift
│ └── AirdropCLITests.swift
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Sources
└── airdrop
│ ├── main.swift
│ ├── ConsoleIO.swift
│ └── AirDropCLI.swift
├── Makefile
├── Package.swift
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import AirdropCLITests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += AirdropCLITests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/airdrop-cli-tests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(AirdropCLITests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/Sources/airdrop/main.swift:
--------------------------------------------------------------------------------
1 | //
2 | // main.swift
3 | // airdrop
4 | //
5 | // Created by Volodymyr Klymenko on 2020-12-30.
6 | //
7 |
8 | import Cocoa
9 |
10 | let airDropCLI = AirDropCLI()
11 | let app = NSApplication.shared
12 |
13 | app.delegate = airDropCLI
14 | app.run()
15 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PREFIX ?= /usr/local
2 | BINDIR := $(PREFIX)/bin
3 |
4 | all: build
5 |
6 | build:
7 | swift build -c release --disable-sandbox $(FLAGS)
8 |
9 | install: build
10 | install -d $(DESTDIR)$(BINDIR)
11 | install -m 755 ".build/release/airdrop" "$(DESTDIR)$(BINDIR)"
12 |
13 | uninstall:
14 | rm -rf $(DESTDIR)$(BINDIR)/airdrop
15 |
16 | clean:
17 | rm -rf .build
18 |
19 | .PHONY: all build install uninstall clean
20 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
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: "airdrop-cli",
8 | targets: [
9 | .target(
10 | name: "airdrop",
11 | dependencies: []),
12 | .testTarget(
13 | name: "airdrop-cli-tests",
14 | dependencies: ["airdrop"]),
15 | ]
16 | )
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Volodymyr Klymenko
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/airdrop/ConsoleIO.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConsoleIO.swift
3 | // airdrop
4 | //
5 | // Created by Volodymyr Klymenko on 2020-12-30.
6 | //
7 |
8 | import Foundation
9 |
10 | enum OutputType {
11 | case error
12 | case standard
13 | }
14 |
15 | class ConsoleIO {
16 | func writeMessage(_ message: String, to: OutputType = .standard) {
17 | switch to {
18 | case .standard:
19 | print("\(message)")
20 | case .error:
21 | fputs("\n❌ Error: \(message)\n", stderr)
22 | }
23 | }
24 |
25 | func printUsage() {
26 | let executableName = (CommandLine.arguments[0] as NSString).lastPathComponent
27 |
28 | writeMessage("USAGE: \(executableName) [file2] [file3] ...")
29 | writeMessage(" file1, file2, file3, ... – URLs or paths to files to AirDrop")
30 | writeMessage(" You can specify multiple items - both local files and web URLs, and you can mix them too.")
31 | writeMessage(" You can also pipe input from other commands: command | \(executableName)")
32 | writeMessage("\nEXAMPLES:")
33 | writeMessage(" \(executableName) document.pdf")
34 | writeMessage(" \(executableName) image1.jpg image2.png")
35 | writeMessage(" \(executableName) file.txt https://apple.com/")
36 | writeMessage(" find . -name '*.pdf' | \(executableName) -")
37 | writeMessage("\nOPTIONS:")
38 | writeMessage(" -h, --help – print help info")
39 | writeMessage(" - – read file paths from stdin")
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Tests/airdrop-cli-tests/AirdropCLITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import class Foundation.Bundle
3 |
4 | final class AirdropCLITests: XCTestCase {
5 | func testExample() throws {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 |
10 | // Some of the APIs that we use below are available in macOS 10.13 and above.
11 | guard #available(macOS 10.13, *) else {
12 | return
13 | }
14 |
15 | let fooBinary = productsDirectory.appendingPathComponent("airdrop")
16 |
17 | let process = Process()
18 | process.executableURL = fooBinary
19 |
20 | let pipe = Pipe()
21 | process.standardOutput = pipe
22 |
23 | try process.run()
24 | process.waitUntilExit()
25 |
26 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
27 | let output = String(data: data, encoding: .utf8)
28 |
29 | XCTAssertEqual(output, "Hello, world!\n")
30 | }
31 |
32 | /// Returns path to the built products directory.
33 | var productsDirectory: URL {
34 | #if os(macOS)
35 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
36 | return bundle.bundleURL.deletingLastPathComponent()
37 | }
38 | fatalError("couldn't find the products directory")
39 | #else
40 | return Bundle.main.bundleURL
41 | #endif
42 | }
43 |
44 | static var allTests = [
45 | ("testExample", testExample),
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # airdrop-cli
2 |
3 | A command-line tool that allows you to share files and URLs with Apple
4 | devices using AirDrop from your terminal.
5 |
6 | ## Prerequisities
7 | - Xcode
8 | - Xcode Command Line Tools
9 |
10 | ## Installation
11 |
12 | `airdrop-cli` is available for install with [Homebrew](https://brew.sh/)
13 |
14 | ```
15 | brew install vldmrkl/formulae/airdrop-cli
16 | ```
17 |
18 | or
19 |
20 | ```
21 | brew tap vldmrkl/formulae
22 | brew install airdrop-cli
23 | ```
24 |
25 | ### Building from source
26 |
27 | Before attempting to build this project from its source code, make sure
28 | that you have Xcode version 11.4 (or higher) & GNU `make` installed.
29 |
30 | Then, clone the project and use the Makefile
31 |
32 | ```
33 | git clone https://github.com/vldmrkl/airdrop-cli.git
34 | cd airdrop-cli
35 | make
36 | sudo make install
37 | ```
38 |
39 | By default, make will build and install the project within
40 | `/usr/local/bin`. This project location can be changed by appending
41 | a new prefix to the `PREFIX` variable.
42 |
43 | ```
44 | make PREFIX="YOUR_PREFIX_HERE"
45 | ```
46 |
47 | You can append other Swift flags, in case you may need them for your
48 | specific build, to the `FLAGS` variable.
49 |
50 | ## Usage
51 |
52 | 
53 |
54 | To airdrop files, run:
55 |
56 | ```
57 | airdrop /path/to/your/file
58 | ```
59 |
60 | You can also airdrop URLs:
61 |
62 | ```
63 | airdrop https://apple.com/
64 | ```
65 |
66 | You can pass as many paths as you want. As long as theese file URLs are correct,
67 | the command will work.
68 |
--------------------------------------------------------------------------------
/Sources/airdrop/AirDropCLI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AirDropCLI.swift
3 | // airdrop
4 | //
5 | // Created by Volodymyr Klymenko on 2020-12-30.
6 | //
7 |
8 | import Foundation
9 |
10 | import Cocoa
11 |
12 | enum OptionType: String {
13 | case help = "h"
14 | case unknown
15 |
16 | init(value: String) {
17 | switch value {
18 | case "-h", "--help": self = .help
19 | default: self = .unknown
20 | }
21 | }
22 | }
23 |
24 | class AirDropCLI: NSObject, NSApplicationDelegate, NSSharingServiceDelegate {
25 | let consoleIO = ConsoleIO()
26 | private var isIndividualSharing = false
27 | private var individualSharingItems: [URL] = []
28 | private var individualSharingSuccessful = 0
29 | private var individualSharingFailed = 0
30 | private var sharingStartTime: Date?
31 |
32 | func applicationDidFinishLaunching(_ aNotification: Notification) {
33 | let argCount = Int(CommandLine.argc)
34 |
35 | if argCount >= 2 {
36 | let argument = CommandLine.arguments[1]
37 | if argCount == 2 && argument.hasPrefix("-") {
38 | if argument == "-" {
39 | // Process stdin
40 | let stdinPaths = readPathsFromStdin()
41 | if stdinPaths.isEmpty {
42 | consoleIO.printUsage()
43 | exit(0)
44 | }
45 | shareFiles(stdinPaths)
46 | } else {
47 | let (option, _) = getOption(argument)
48 |
49 | if option == .help {
50 | consoleIO.printUsage()
51 | } else {
52 | consoleIO.writeMessage("Unknown option, see usage.\n", to: .error)
53 | consoleIO.printUsage()
54 | }
55 |
56 | exit(0)
57 | }
58 | } else {
59 | let pathsToFiles = Array(CommandLine.arguments[1 ..< argCount])
60 | shareFiles(pathsToFiles)
61 | }
62 | } else {
63 | consoleIO.printUsage()
64 | exit(0)
65 | }
66 |
67 | if #available(macOS 13.0, *) {
68 | NSApp.setActivationPolicy(.accessory)
69 | }
70 | }
71 |
72 | func getOption(_ option: String) -> (option:OptionType, value: String) {
73 | return (OptionType(value: option), option)
74 | }
75 |
76 | func shareFiles(_ pathsToFiles: [String]) {
77 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop)
78 | else {
79 | exit(2)
80 | }
81 |
82 | var filesToShare: [URL] = []
83 | var invalidPaths: [String] = []
84 |
85 | for pathToFile in pathsToFiles {
86 | if let url = URL(string: pathToFile),
87 | let scheme = url.scheme?.lowercased(),
88 | ["http", "https"].contains(scheme) {
89 | filesToShare.append(url)
90 | } else {
91 | let fileURL: URL = NSURL.fileURL(withPath: pathToFile, isDirectory: false)
92 |
93 | if FileManager.default.fileExists(atPath: fileURL.path) {
94 | filesToShare.append(fileURL.standardizedFileURL)
95 | } else {
96 | invalidPaths.append(pathToFile)
97 | }
98 | }
99 | }
100 |
101 | if !invalidPaths.isEmpty {
102 | consoleIO.writeMessage("Warning: The following paths are invalid")
103 | for path in invalidPaths {
104 | consoleIO.writeMessage(" \(path)")
105 | }
106 | }
107 |
108 | guard !filesToShare.isEmpty else {
109 | consoleIO.writeMessage("Warning: No valid files or URLs to share.")
110 | exit(1)
111 | }
112 |
113 | consoleIO.writeMessage("Sharing \(filesToShare.count) items:")
114 | for (index, url) in filesToShare.enumerated() {
115 | consoleIO.writeMessage(" \(index + 1). \(url)")
116 | }
117 |
118 | let hasURLs = filesToShare.contains { $0.scheme == "http" || $0.scheme == "https" }
119 | let hasFiles = filesToShare.contains { $0.scheme == "file" }
120 | let isMixedContent: Bool = hasURLs && hasFiles
121 |
122 | if isMixedContent {
123 | // Currently, AirDrop does not support sharing both URLs and files at once. Therefore, we need to share them individually.
124 | shareItemsIndividually(service: service, filesToShare)
125 | } else {
126 | if service.canPerform(withItems: filesToShare) {
127 | service.delegate = self
128 | service.perform(withItems: filesToShare)
129 | } else {
130 | // If we can't share all items at once, for example, when there is more than 1 URL, we need to share them individually
131 | shareItemsIndividually(service: service, filesToShare)
132 | }
133 | }
134 | }
135 |
136 |
137 | func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
138 | if isIndividualSharing {
139 | individualSharingSuccessful += 1
140 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop) else {
141 | exit(2)
142 | }
143 | shareNextItem(service: service, remainingItems: individualSharingItems)
144 | } else {
145 | consoleIO.writeMessage("✅ Sharing completed: \(items.count) successful")
146 | exit(0)
147 | }
148 | }
149 |
150 | func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) {
151 | if isIndividualSharing {
152 | individualSharingFailed += 1
153 | consoleIO.writeMessage("Failed to share item: \(error.localizedDescription)", to: .error)
154 |
155 | guard let service: NSSharingService = NSSharingService(named: .sendViaAirDrop) else {
156 | exit(2)
157 | }
158 | shareNextItem(service: service, remainingItems: individualSharingItems)
159 | } else {
160 | consoleIO.writeMessage(error.localizedDescription, to: .error)
161 | exit(1)
162 | }
163 | }
164 |
165 | func sharingService(_ sharingService: NSSharingService, sourceFrameOnScreenForShareItem item: Any) -> NSRect {
166 | return NSRect(x: 0, y: 0, width: 400, height: 100)
167 | }
168 |
169 | func sharingService(_ sharingService: NSSharingService, sourceWindowForShareItems items: [Any], sharingContentScope: UnsafeMutablePointer) -> NSWindow? {
170 | let airDropMenuWindow = NSWindow(contentRect: .init(origin: .zero,
171 | size: .init(width: 1,
172 | height: 1)),
173 | styleMask: [.closable],
174 | backing: .buffered,
175 | defer: false)
176 |
177 | airDropMenuWindow.center()
178 | airDropMenuWindow.level = .popUpMenu
179 | airDropMenuWindow.makeKeyAndOrderFront(nil)
180 |
181 | return airDropMenuWindow
182 | }
183 |
184 | private func shareItemsIndividually(service: NSSharingService, _ items: [URL]) {
185 | isIndividualSharing = true
186 | individualSharingItems = items
187 | individualSharingSuccessful = 0
188 | individualSharingFailed = 0
189 |
190 | shareNextItem(service: service, remainingItems: items)
191 | }
192 |
193 | private func shareNextItem(service: NSSharingService, remainingItems: [URL]) {
194 | guard !remainingItems.isEmpty else {
195 | consoleIO.writeMessage("✅ Sharing completed: \(individualSharingSuccessful) successful, \(individualSharingFailed) failed")
196 | exit(individualSharingFailed > 0 ? 1 : 0)
197 | }
198 |
199 | let currentItem = remainingItems.first!
200 | let remainingItemsAfterCurrent = Array(remainingItems.dropFirst())
201 |
202 | if service.canPerform(withItems: [currentItem]) {
203 | service.delegate = self
204 | service.perform(withItems: [currentItem])
205 | individualSharingItems = remainingItemsAfterCurrent
206 | } else {
207 | consoleIO.writeMessage("Cannot share: \(currentItem)", to: .error)
208 | individualSharingFailed += 1
209 | shareNextItem(service: service, remainingItems: remainingItemsAfterCurrent)
210 | }
211 | }
212 |
213 | private func readPathsFromStdin() -> [String] {
214 | var paths: [String] = []
215 |
216 | while let line = readLine() {
217 | let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
218 | if !trimmedLine.isEmpty {
219 | paths.append(trimmedLine)
220 | }
221 | }
222 |
223 | return paths
224 | }
225 | }
226 |
--------------------------------------------------------------------------------