|
| 1 | +// |
| 2 | +// AppMover.swift |
| 3 | +// AppMover |
| 4 | +// |
| 5 | +// Created by Oskar Groth on 2019-12-20. |
| 6 | +// Copyright © 2019 Oskar Groth. All rights reserved. |
| 7 | +// |
| 8 | + |
| 9 | +import AppKit |
| 10 | +import Security |
| 11 | + |
| 12 | +public enum AppMover { |
| 13 | + |
| 14 | + public static func moveIfNecessary() { |
| 15 | + let fm = FileManager.default |
| 16 | + guard !Bundle.main.isInstalled, |
| 17 | + let applications = preferredInstallDirectory() else { return } |
| 18 | + let bundleUrl = Bundle.main.bundleURL |
| 19 | + let bundleName = bundleUrl.lastPathComponent |
| 20 | + let destinationUrl = applications.appendingPathComponent(bundleName) |
| 21 | + let needDestAuth = fm.fileExists(atPath: destinationUrl.path) && !fm.isWritableFile(atPath: destinationUrl.path) |
| 22 | + let needAuth = needDestAuth || !fm.isWritableFile(atPath: applications.path) |
| 23 | + |
| 24 | + // Activate app -- work-around for focus issues related to "scary file from |
| 25 | + // internet" OS dialog. |
| 26 | + if !NSApp.isActive { |
| 27 | + NSApp.activate(ignoringOtherApps: true) |
| 28 | + } |
| 29 | + |
| 30 | + let alert = NSAlert() |
| 31 | + alert.messageText = "Move to Applications folder" |
| 32 | + alert.informativeText = "\(Bundle.main.localizedName) needs to move to your Applications folder in order to work properly." |
| 33 | + if needAuth { |
| 34 | + alert.informativeText.append(" You need to authenticate with your administrator password to complete this step.") |
| 35 | + } |
| 36 | + alert.addButton(withTitle: "Move to Applications Folder") |
| 37 | + alert.addButton(withTitle: "Do Not Move") |
| 38 | + guard alert.runModal() == .alertFirstButtonReturn else { |
| 39 | + return |
| 40 | + } |
| 41 | + if needAuth { |
| 42 | + let result = authorizedInstall(from: bundleUrl, to: destinationUrl) |
| 43 | + guard !result.cancelled else { moveIfNecessary(); return } |
| 44 | + guard result.success else { |
| 45 | + NSApplication.shared.terminate(self) |
| 46 | + return |
| 47 | + } |
| 48 | + } else { |
| 49 | + if fm.fileExists(atPath: destinationUrl.path) { |
| 50 | + if AppMover.isApplicationAtUrlRunning(destinationUrl) { |
| 51 | + NSWorkspace.shared.open(destinationUrl) |
| 52 | + return |
| 53 | + } else { |
| 54 | + guard (try? fm.trashItem(at: destinationUrl, resultingItemURL: nil)) != nil else { |
| 55 | + return |
| 56 | + } |
| 57 | + } |
| 58 | + } |
| 59 | + guard (try? fm.copyItem(at: bundleUrl, to: destinationUrl)) != nil else { |
| 60 | + return |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + // Trash the original app |
| 65 | + _ = try? fm.removeItem(at: bundleUrl) |
| 66 | + |
| 67 | + relaunch(at: destinationUrl.path, completionCallback: { |
| 68 | + DispatchQueue.main.async { |
| 69 | + exit(0) |
| 70 | + } |
| 71 | + }) |
| 72 | + |
| 73 | + } |
| 74 | + |
| 75 | + static func authorizedInstall(from sourceURL: URL, to destinationURL: URL) -> (cancelled: Bool, success: Bool) { |
| 76 | + guard destinationURL.representsBundle, |
| 77 | + destinationURL.isValid, |
| 78 | + sourceURL.isValid else { |
| 79 | + return (false, false) |
| 80 | + } |
| 81 | + return sourceURL.withUnsafeFileSystemRepresentation({ sourcePath -> (cancelled: Bool, success: Bool) in |
| 82 | + return destinationURL.withUnsafeFileSystemRepresentation({ destinationPath -> (cancelled: Bool, success: Bool) in |
| 83 | + guard let sourcePath = sourcePath, let destinationPath = destinationPath else { return (false, false) } |
| 84 | + let deleteCommand = "rm -rf '\(String(cString: destinationPath))'" |
| 85 | + let copyCommand = "cp -pR '\(String(cString: sourcePath))' '\(String(cString: destinationPath))'" |
| 86 | + guard let script = NSAppleScript(source: "do shell script \"\(deleteCommand) && \(copyCommand)\" with administrator privileges") else { |
| 87 | + return (false, false) |
| 88 | + } |
| 89 | + var error: NSDictionary? |
| 90 | + script.executeAndReturnError(&error) |
| 91 | + return ((error?[NSAppleScript.errorNumber] as? Int16) == -128, error == nil) |
| 92 | + }) |
| 93 | + }) |
| 94 | + } |
| 95 | + |
| 96 | + static func preferredInstallDirectory() -> URL? { |
| 97 | + let fm = FileManager.default |
| 98 | + let dirs = fm.urls(for: .applicationDirectory, in: .allDomainsMask) |
| 99 | + // Find Applications dir with the most apps that isn't system protected |
| 100 | + return dirs.map({ $0.resolvingSymlinksInPath() }).filter({ url in |
| 101 | + var isDir: ObjCBool = false |
| 102 | + fm.fileExists(atPath: url.path, isDirectory: &isDir) |
| 103 | + return isDir.boolValue && url.path != "/System/Applications" |
| 104 | + }).sorted(by: { left, right in |
| 105 | + return left.numberOfFilesInDirectory < right.numberOfFilesInDirectory |
| 106 | + }).last |
| 107 | + } |
| 108 | + |
| 109 | + static func isApplicationAtUrlRunning(_ url: URL) -> Bool { |
| 110 | + let url = url.standardized |
| 111 | + return NSWorkspace.shared.runningApplications.contains(where: { |
| 112 | + $0.bundleURL?.standardized == url |
| 113 | + }) |
| 114 | + } |
| 115 | + |
| 116 | + public static func relaunch(at path: String, completionCallback: @escaping () -> Void) { |
| 117 | + let pid = ProcessInfo.processInfo.processIdentifier |
| 118 | + Process.runTask(command: "/usr/bin/xattr", arguments: ["-d", "-r", "com.apple.quarantine", path], completion: { _ in |
| 119 | + let waitForExitScript = "(while /bin/kill -0 \(pid) >&/dev/null; do /bin/sleep 0.1; done; /usr/bin/open \"\(path)\") &" |
| 120 | + Process.runTask(command: "/bin/sh", arguments: ["-c", waitForExitScript]) |
| 121 | + completionCallback() |
| 122 | + }) |
| 123 | + } |
| 124 | + |
| 125 | +} |
0 commit comments