diff --git a/RSwitch.xcodeproj/project.pbxproj b/RSwitch.xcodeproj/project.pbxproj index e7f739a..2fc4c77 100644 --- a/RSwitch.xcodeproj/project.pbxproj +++ b/RSwitch.xcodeproj/project.pbxproj @@ -8,6 +8,15 @@ /* Begin PBXBuildFile section */ 01073F0F2311AE2E007162C9 /* String+Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F0E2311AE2E007162C9 /* String+Version.swift */; }; + 01073F112311E0F7007162C9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F102311E0F7007162C9 /* Alerts.swift */; }; + 01073F132311E1CF007162C9 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F122311E1CF007162C9 /* Utils.swift */; }; + 01073F152311E370007162C9 /* Notify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F142311E370007162C9 /* Notify.swift */; }; + 01073F172311E397007162C9 /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F162311E397007162C9 /* Menu.swift */; }; + 01073F192311E3B8007162C9 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F182311E3B8007162C9 /* Bundle.swift */; }; + 01073F1B2311E613007162C9 /* DownloadTarball.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F1A2311E613007162C9 /* DownloadTarball.swift */; }; + 01073F1D2311E64E007162C9 /* DownloadRStudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F1C2311E64E007162C9 /* DownloadRStudio.swift */; }; + 01073F1F2311E67D007162C9 /* HandleUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F1E2311E67D007162C9 /* HandleUpdate.swift */; }; + 01073F212311E6BD007162C9 /* HandleSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01073F202311E6BD007162C9 /* HandleSwitch.swift */; }; 0178970D230ED25100F8F5BC /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0178970C230ED25100F8F5BC /* AboutViewController.swift */; }; 01F3EF0C230E635300DF5DF9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F3EF0B230E635300DF5DF9 /* AppDelegate.swift */; }; 01F3EF0E230E635300DF5DF9 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F3EF0D230E635300DF5DF9 /* ViewController.swift */; }; @@ -18,6 +27,15 @@ /* Begin PBXFileReference section */ 01073F0E2311AE2E007162C9 /* String+Version.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Version.swift"; sourceTree = ""; }; + 01073F102311E0F7007162C9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; + 01073F122311E1CF007162C9 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; + 01073F142311E370007162C9 /* Notify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notify.swift; sourceTree = ""; }; + 01073F162311E397007162C9 /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = ""; }; + 01073F182311E3B8007162C9 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + 01073F1A2311E613007162C9 /* DownloadTarball.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTarball.swift; sourceTree = ""; }; + 01073F1C2311E64E007162C9 /* DownloadRStudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadRStudio.swift; sourceTree = ""; }; + 01073F1E2311E67D007162C9 /* HandleUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleUpdate.swift; sourceTree = ""; }; + 01073F202311E6BD007162C9 /* HandleSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleSwitch.swift; sourceTree = ""; }; 0178970C230ED25100F8F5BC /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; 01F3EF08230E635300DF5DF9 /* RSwitch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RSwitch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 01F3EF0B230E635300DF5DF9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -42,6 +60,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 01073F232311E859007162C9 /* swift */ = { + isa = PBXGroup; + children = ( + 01F3EF0B230E635300DF5DF9 /* AppDelegate.swift */, + 01073F102311E0F7007162C9 /* Alerts.swift */, + 01073F202311E6BD007162C9 /* HandleSwitch.swift */, + 01073F1E2311E67D007162C9 /* HandleUpdate.swift */, + 01073F1C2311E64E007162C9 /* DownloadRStudio.swift */, + 01073F1A2311E613007162C9 /* DownloadTarball.swift */, + 01073F182311E3B8007162C9 /* Bundle.swift */, + 01073F162311E397007162C9 /* Menu.swift */, + 01073F142311E370007162C9 /* Notify.swift */, + 01073F0E2311AE2E007162C9 /* String+Version.swift */, + 01073F122311E1CF007162C9 /* Utils.swift */, + 01F3EF0D230E635300DF5DF9 /* ViewController.swift */, + 0178970C230ED25100F8F5BC /* AboutViewController.swift */, + ); + path = swift; + sourceTree = ""; + }; 01F3EEFF230E635300DF5DF9 = { isa = PBXGroup; children = ( @@ -63,12 +101,9 @@ 01F3EF0A230E635300DF5DF9 /* RSwitch */ = { isa = PBXGroup; children = ( - 01F3EF0B230E635300DF5DF9 /* AppDelegate.swift */, - 01F3EF0D230E635300DF5DF9 /* ViewController.swift */, + 01073F232311E859007162C9 /* swift */, 01F3EF0F230E635500DF5DF9 /* Assets.xcassets */, 01F3EF11230E635500DF5DF9 /* Main.storyboard */, - 0178970C230ED25100F8F5BC /* AboutViewController.swift */, - 01073F0E2311AE2E007162C9 /* String+Version.swift */, 01F3EF14230E635500DF5DF9 /* Info.plist */, ); path = RSwitch; @@ -205,10 +240,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 01073F212311E6BD007162C9 /* HandleSwitch.swift in Sources */, + 01073F1F2311E67D007162C9 /* HandleUpdate.swift in Sources */, + 01073F1B2311E613007162C9 /* DownloadTarball.swift in Sources */, 0178970D230ED25100F8F5BC /* AboutViewController.swift in Sources */, + 01073F192311E3B8007162C9 /* Bundle.swift in Sources */, + 01073F152311E370007162C9 /* Notify.swift in Sources */, + 01073F1D2311E64E007162C9 /* DownloadRStudio.swift in Sources */, + 01073F172311E397007162C9 /* Menu.swift in Sources */, + 01073F112311E0F7007162C9 /* Alerts.swift in Sources */, 01F3EF0E230E635300DF5DF9 /* ViewController.swift in Sources */, 01F3EF0C230E635300DF5DF9 /* AppDelegate.swift in Sources */, 01073F0F2311AE2E007162C9 /* String+Version.swift in Sources */, + 01073F132311E1CF007162C9 /* Utils.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/RSwitch/AppDelegate.swift b/RSwitch/AppDelegate.swift deleted file mode 100644 index e23c404..0000000 --- a/RSwitch/AppDelegate.swift +++ /dev/null @@ -1,443 +0,0 @@ -import Cocoa -import SwiftSoup - -// Show an informational alert -public func infoAlert(_ message: String, _ extra: String? = nil, style: NSAlert.Style = NSAlert.Style.informational) { - let alert = NSAlert() - alert.messageText = message - if extra != nil { alert.informativeText = extra! } - alert.alertStyle = style - alert.runModal() -} - -// Show an informational alert and then quit -public func quitAlert(_ message: String, _ extra: String? = nil) { - infoAlert(message, "The application will now quit.", style: NSAlert.Style.critical) - NSApp.terminate(nil) -} - -@NSApplicationMain -class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDelegate { - - var mainStoryboard: NSStoryboard! - var abtController: NSWindowController! - - let macos_r_framework_dir = "/Library/Frameworks/R.framework/Versions" // Where the official R installs go - - struct app_urls { - static let mac_r_project = "https://mac.r-project.org/" - static let macos_cran = "https://cran.rstudio.org/bin/macosx/" - static let r_sig_mac = "https://stat.ethz.ch/pipermail/r-sig-mac/" - static let rstudio_dailies = "https://dailies.rstudio.com/rstudio/oss/mac/" - static let latest_rstudio_dailies = "https://www.rstudio.org/download/latest/daily/desktop/mac/RStudio-latest.dmg" - static let browse_r_admin_macos = "https://cran.rstudio.org/doc/manuals/R-admin.html#Installing-R-under-macOS" - static let version_check = "https://rud.is/rswitch/releases/current-version.txt" - static let releases = "https://git.rud.is/hrbrmstr/RSwitch/releases" - } - - // Get the bar setup - let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - let statusMenu = NSMenu() - - let quitItem = NSMenuItem(title: NSLocalizedString("Quit", comment: "Quit menu item"), action: #selector(NSApp.terminate), keyEquivalent: "q") - - var rdevel_enabled: Bool! - var rstudio_enabled: Bool! - - override init() { - - super.init() - - statusMenu.delegate = self - - // dial by IconMark from the Noun Project - statusItem.button?.image = #imageLiteral(resourceName: "RSwitch") - statusItem.menu = statusMenu - - mainStoryboard = NSStoryboard(name: "Main", bundle: nil) - abtController = (mainStoryboard.instantiateController(withIdentifier: "aboutPanelController") as! NSWindowController) - - rdevel_enabled = true - rstudio_enabled = true - - URLCache.shared = URLCache(memoryCapacity: 0, diskCapacity: 0, diskPath: nil) - - } - - func applicationDidFinishLaunching(_ aNotification: Notification) { - } - - func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down app - } - - func notifyUser(title: String? = nil, subtitle: String? = nil, text: String? = nil) -> Void { - - let notification = NSUserNotification() - - notification.title = title - notification.subtitle = subtitle - notification.informativeText = text - - notification.soundName = NSUserNotificationDefaultSoundName - - NSUserNotificationCenter.default.delegate = self - NSUserNotificationCenter.default.deliver(notification) - - } - - - func userNotificationCenter(_ center: NSUserNotificationCenter, shouldPresent notification: NSUserNotification) -> Bool { - return true - } - - // The core worker function. Receives the basename of the selected directory - // then removes the current alias and creates the new one. - @objc func handleSwitch(_ sender: NSMenuItem?) { - - let fm = FileManager.default; - let title = sender?.title - - do { - try fm.removeItem(atPath: macos_r_framework_dir + "/" + "Current") - } catch { - self.notifyUser(title: "Action failed", text: "Failed to remove 'Current' alias" + macos_r_framework_dir + "/" + "Current") - } - - do { - try fm.createSymbolicLink( - at: NSURL(fileURLWithPath: macos_r_framework_dir + "/" + "Current") as URL, - withDestinationURL: NSURL(fileURLWithPath: macos_r_framework_dir + "/" + title!) as URL - ) - self.notifyUser(title: "Success", text: "Current R version switched to " + title!) - } catch { - self.notifyUser(title: "Action failed", text: "Failed to create alias for " + macos_r_framework_dir + "/" + title!) - } - - } - - // browse macOS dev page - @objc func browse_r_macos_dev_page(_ sender: NSMenuItem?) { - let url = URL(string: app_urls.mac_r_project)! - NSWorkspace.shared.open(url) - } - - // browse macOS dev page - @objc func browse_r_macos_cran_page(_ sender: NSMenuItem?) { - let url = URL(string: app_urls.macos_cran)! - NSWorkspace.shared.open(url) - } - - // browse macOS dev page - @objc func browse_r_sig_mac_page(_ sender: NSMenuItem?) { - let url = URL(string: app_urls.r_sig_mac)! - NSWorkspace.shared.open(url) - } - - // browse RStudio macOS Dailies - @objc func browse_rstudio_mac_dailies_page(_ sender: NSMenuItem?) { - let url = URL(string: app_urls.rstudio_dailies)! - NSWorkspace.shared.open(url) - } - - - // browse R Install/Admin macOS section - @objc func browse_r_admin_macos_page(_ sender: NSMenuItem?) { - let url = URL(string: app_urls.browse_r_admin_macos)! - NSWorkspace.shared.open(url) - } - - // Show about dialog - @objc func about(_ sender: NSMenuItem?) { - abtController.showWindow(self) - } - - // Show the framework dir in a new Finder window - @objc func openFrameworksDir(_ sender: NSMenuItem?) { - NSWorkspace.shared.openFile(macos_r_framework_dir, withApplication: "Finder") - } - - // Launch RStudio - @objc func launchRStudio(_ sender: NSMenuItem?) { - NSWorkspace.shared.launchApplication("RStudio.app") - } - - // Launch R.app - @objc func launchRApp(_ sender: NSMenuItem?) { - NSWorkspace.shared.launchApplication("R.app") - } - - // Launch R.app - @objc func checkForUpdate(_ sender: NSMenuItem?) { - - let url = URL(string: app_urls.version_check) - - do { - URLCache.shared.removeAllCachedResponses() - var version = try String.init(contentsOf: url!) - version = version.trimmingCharacters(in: .whitespacesAndNewlines) - if (version.isVersion(greaterThan: Bundle.main.releaseVersionNumber!)) { - let url = URL(string: app_urls.releases) - NSWorkspace.shared.open(url!) - } else { - self.notifyUser(title: "RSwitch", text: "You are running the latest version of RSwitch.") - } - } catch { - self.notifyUser(title: "Action failed", subtitle: "Update check", text: "Error: \(error)") - } - - - } - - // Download latest rstudio daily build - @objc func download_latest_rstudio(_ sender: NSMenuItem?) { - - self.rstudio_enabled = false - - let url = URL(string: app_urls.rstudio_dailies) - - do { - - let html = try String.init(contentsOf: url!) - let document = try SwiftSoup.parse(html) - - let link = try document.select("td > a").first! - let href = try link.attr("href") - let dlurl = URL(string: href)! - let dldir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - var dlfile = dldir - - dlfile.appendPathComponent(dlurl.lastPathComponent) - - print("RStudio href: " + href) - - if (FileManager.default.fileExists(atPath: dlfile.relativePath)) { - - self.notifyUser(title: "Action required", subtitle: "RStudio Download", text: "A local copy of the latest RStudio daily already exists. Please remove or rename it if you wish to re-download it.") - - NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") - NSWorkspace.shared.activateFileViewerSelecting([dlfile]) - - self.rstudio_enabled = true - - } else { - - print("Timeout value: ", URLSession.shared.configuration.timeoutIntervalForRequest) - - let task = URLSession.shared.downloadTask(with: dlurl) { - tempURL, response, error in - - if (error != nil) { - self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error: " + error!.localizedDescription) - } else if (response != nil) { - - let status = (response as? HTTPURLResponse)!.statusCode - if (status < 300) { - - guard let fileURL = tempURL else { - DispatchQueue.main.async { [weak self] in self?.rstudio_enabled = true } - return - } - - do { - try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) - try FileManager.default.moveItem(at: fileURL, to: dlfile) - self.notifyUser(title: "Success", subtitle: "RStudio Download", text: "Download of latest RStudio daily (" + dlurl.lastPathComponent + ") successful.") - NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") - NSWorkspace.shared.activateFileViewerSelecting([dlfile]) - } catch { - self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error: \(error)") - } - - } else { - self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error downloading latest RStudio daily. Status code: " + String(status)) - - } - } - - DispatchQueue.main.async { [weak self] in self?.rstudio_enabled = true } - - } - - task.resume() - } - - } catch { - self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error downloading and saving latest RStudio daily.") - } - - } - - // Download latest r-devel tarball - @objc func download_latest_tarball(_ sender: NSMenuItem?) { - - self.rdevel_enabled = false - - let dlurl = URL(string: "https://mac.r-project.org/el-capitan/R-devel/R-devel-el-capitan-sa-x86_64.tar.gz")! - let dldir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - var dlfile = dldir - - dlfile.appendPathComponent("R-devel-el-capitan-sa-x86_64.tar.gz") - - if (FileManager.default.fileExists(atPath: dlfile.relativePath)) { - - self.notifyUser(title: "Action required", subtitle: "r-devel Download", text: "R-devel tarball already exists. Please remove or rename it before downloading.") - - NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") - NSWorkspace.shared.activateFileViewerSelecting([dlfile]) - - self.rdevel_enabled = true - } else { - - let task = URLSession.shared.downloadTask(with: dlurl) { - tempURL, response, error in - - if (error != nil) { - self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error: " + error!.localizedDescription) - } else if (response != nil) { - - let status = (response as? HTTPURLResponse)!.statusCode - if (status < 300) { - - guard let fileURL = tempURL else { - DispatchQueue.main.async { [weak self] in self?.rdevel_enabled = true } - return - } - - do { - try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) - try FileManager.default.moveItem(at: fileURL, to: dlfile) - self.notifyUser(title: "Success", subtitle: "r-devel Download", text: "Download of latest r-devel (" + dlurl.lastPathComponent + ") successful.") - NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") - NSWorkspace.shared.activateFileViewerSelecting([dlfile]) - } catch { - self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error: \(error)") - } - - } else { - self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error downloading latest r-devel. Status code: " + String(status)) - } - } - - DispatchQueue.main.async { [weak self] in self?.rdevel_enabled = true } - - } - - task.resume() - } - } - -} - -extension Bundle { - var releaseVersionNumber: String? { - return infoDictionary?["CFBundleShortVersionString"] as? String - } - var buildVersionNumber: String? { - return infoDictionary?["CFBundleVersion"] as? String - } - var releaseVersionNumberPretty: String { - return "v\(releaseVersionNumber ?? "1.0.0")" - } -} - -extension AppDelegate: NSMenuDelegate { - - func menuWillOpen(_ menu: NSMenu) { - - if (menu != self.statusMenu) { return } - - // clear the menu - menu.removeAllItems() - - // add selection to open frameworks dir in Finder - menu.addItem(NSMenuItem(title: "Open R Frameworks Directory", action: #selector(openFrameworksDir), keyEquivalent: "" )) - menu.addItem(NSMenuItem.separator()) - - // populate installed versions - let fm = FileManager.default - var targetPath:String? = nil - - do { - - // gets a directory listing - let entries = try fm.contentsOfDirectory(atPath: macos_r_framework_dir) - - // retrieves all versions (excludes hidden files and the Current alias - let versions = entries.sorted().filter { !($0.hasPrefix(".")) && !($0 == "Current") } - let hasCurrent = entries.filter { $0 == "Current" } - - // if there was a Current alias (prbly shld alert if not) - if (hasCurrent.count > 0) { - - // get where Current points to - let furl = NSURL(fileURLWithPath: macos_r_framework_dir + "/" + "Current") - - if (furl.fileReferenceURL() != nil) { - do { - let fdat = try NSURL(resolvingAliasFileAt: furl as URL, options: []) - targetPath = fdat.lastPathComponent! - } catch { - targetPath = furl.path - } - } - - // populate menu items with all installed R versions, ensuring we - // put a checkbox next to the one that is Current - var i = 1 - for version in versions { - let keynum = (i < 10) ? String(i) : "" - let item = NSMenuItem(title: version, action: #selector(handleSwitch), keyEquivalent: keynum) - item.isEnabled = true - if (version == targetPath) { item.state = NSControl.StateValue.on } - item.representedObject = version - menu.addItem(item) - i = i + 1 - } - - } - - } catch { - quitAlert("Failed to list contents of R framework directory. You either do not have R installed or have incorrect permissions set on " + macos_r_framework_dir) - } - - // Add items to download latest r-devel tarball and latest macOS daily - menu.addItem(NSMenuItem.separator()) - - let rdevelItem = NSMenuItem(title: NSLocalizedString("Download latest R-devel tarball", comment: "Download latest tarball item"), action: self.rdevel_enabled ? #selector(download_latest_tarball) : nil, keyEquivalent: "") - rdevelItem.isEnabled = self.rdevel_enabled - menu.addItem(rdevelItem) - - let rstudioItem = NSMenuItem(title: NSLocalizedString("Download latest RStudio daily build", comment: "Download latest RStudio item"), action: self.rstudio_enabled ? #selector(download_latest_rstudio) : nil, keyEquivalent: "") - rstudioItem.isEnabled = self.rstudio_enabled - menu.addItem(rstudioItem) - - - // Add items to open variosu R for macOS pages - menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: NSLocalizedString("Open R for macOS Developers Page…", comment: "Open macOS Dev Page item"), action: #selector(browse_r_macos_dev_page), keyEquivalent: "")) - menu.addItem(NSMenuItem(title: NSLocalizedString("Open R for macOS CRAN Page…", comment: "Open macOS CRAN Page item"), action: #selector(browse_r_macos_cran_page), keyEquivalent: "")) - menu.addItem(NSMenuItem(title: NSLocalizedString("Open R-SIG-Mac Archives Page…", comment: "Open R-SIG-Mac Page item"), action: #selector(browse_r_sig_mac_page), keyEquivalent: "")) - menu.addItem(NSMenuItem(title: NSLocalizedString("Open R Installation/Admin macOS Section…", comment: "Open R Install Page item"), action: #selector(browse_r_admin_macos_page), keyEquivalent: "")) - menu.addItem(NSMenuItem(title: NSLocalizedString("Open RStudio macOS Dailies Page…", comment: "Open RStudio macOS Dailies Page item"), action: #selector(browse_rstudio_mac_dailies_page), keyEquivalent: "")) - - // Add launchers - menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: NSLocalizedString("Launch R GUI", comment: "Launch R GUI item"), action: #selector(launchRApp), keyEquivalent: "")) - menu.addItem(NSMenuItem(title: NSLocalizedString("Launch RStudio", comment: "Launch RStudio item"), action: #selector(launchRStudio), keyEquivalent: "")) - - // Add a About item - menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: NSLocalizedString("Check for update…", comment: "Check for update item"), action: #selector(checkForUpdate), keyEquivalent: "")) - - // Add a About item - menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: NSLocalizedString("About RSwitch…", comment: "About menu item"), action: #selector(about), keyEquivalent: "")) - - // Add a Quit item - menu.addItem(NSMenuItem.separator()) - menu.addItem(quitItem) - - } - -} diff --git a/RSwitch/AboutViewController.swift b/RSwitch/swift/AboutViewController.swift similarity index 100% rename from RSwitch/AboutViewController.swift rename to RSwitch/swift/AboutViewController.swift diff --git a/RSwitch/swift/Alerts.swift b/RSwitch/swift/Alerts.swift new file mode 100644 index 0000000..48a8936 --- /dev/null +++ b/RSwitch/swift/Alerts.swift @@ -0,0 +1,25 @@ +// +// Alerts.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +// Show an informational alert +public func infoAlert(_ message: String, _ extra: String? = nil, style: NSAlert.Style = NSAlert.Style.informational) { + let alert = NSAlert() + alert.messageText = message + if extra != nil { alert.informativeText = extra! } + alert.alertStyle = style + alert.runModal() +} + +// Show an informational alert and then quit +public func quitAlert(_ message: String, _ extra: String? = nil) { + infoAlert(message, "The application will now quit.", style: NSAlert.Style.critical) + NSApp.terminate(nil) +} diff --git a/RSwitch/swift/AppDelegate.swift b/RSwitch/swift/AppDelegate.swift new file mode 100644 index 0000000..49a2b09 --- /dev/null +++ b/RSwitch/swift/AppDelegate.swift @@ -0,0 +1,40 @@ +import Cocoa + +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate { + + var mainStoryboard: NSStoryboard! + var abtController: NSWindowController! + + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let statusMenu = NSMenu() + let quitItem = NSMenuItem(title: NSLocalizedString("Quit", comment: "Quit menu item"), action: #selector(NSApp.terminate), keyEquivalent: "q") + + var rdevel_enabled: Bool! + var rstudio_enabled: Bool! + + override init() { + + super.init() + + statusMenu.delegate = self + + // dial by IconMark from the Noun Project + statusItem.button?.image = #imageLiteral(resourceName: "RSwitch") + statusItem.menu = statusMenu + + mainStoryboard = NSStoryboard(name: "Main", bundle: nil) + abtController = (mainStoryboard.instantiateController(withIdentifier: "aboutPanelController") as! NSWindowController) + + rdevel_enabled = true + rstudio_enabled = true + + URLCache.shared = URLCache(memoryCapacity: 0, diskCapacity: 0, diskPath: nil) + + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { } + + func applicationWillTerminate(_ aNotification: Notification) { } + +} diff --git a/RSwitch/swift/Bundle.swift b/RSwitch/swift/Bundle.swift new file mode 100644 index 0000000..8d2c31d --- /dev/null +++ b/RSwitch/swift/Bundle.swift @@ -0,0 +1,21 @@ +// +// Bundle.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation + +extension Bundle { + var releaseVersionNumber: String? { + return infoDictionary?["CFBundleShortVersionString"] as? String + } + var buildVersionNumber: String? { + return infoDictionary?["CFBundleVersion"] as? String + } + var releaseVersionNumberPretty: String { + return "v\(releaseVersionNumber ?? "1.0.0")" + } +} diff --git a/RSwitch/swift/DownloadRStudio.swift b/RSwitch/swift/DownloadRStudio.swift new file mode 100644 index 0000000..ffa7c43 --- /dev/null +++ b/RSwitch/swift/DownloadRStudio.swift @@ -0,0 +1,94 @@ +// +// DownloadRStudio.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa +import SwiftSoup + +extension AppDelegate { + + // Download latest rstudio daily build + @objc func download_latest_rstudio(_ sender: NSMenuItem?) { + + self.rstudio_enabled = false + + let url = URL(string: app_urls.rstudio_dailies) + + do { + + let html = try String.init(contentsOf: url!) + let document = try SwiftSoup.parse(html) + + let link = try document.select("td > a").first! + let href = try link.attr("href") + let dlurl = URL(string: href)! + let dldir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + var dlfile = dldir + + dlfile.appendPathComponent(dlurl.lastPathComponent) + + print("RStudio href: " + href) + + if (FileManager.default.fileExists(atPath: dlfile.relativePath)) { + + self.notifyUser(title: "Action required", subtitle: "RStudio Download", text: "A local copy of the latest RStudio daily already exists. Please remove or rename it if you wish to re-download it.") + + NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") + NSWorkspace.shared.activateFileViewerSelecting([dlfile]) + + self.rstudio_enabled = true + + } else { + + print("Timeout value: ", URLSession.shared.configuration.timeoutIntervalForRequest) + + let task = URLSession.shared.downloadTask(with: dlurl) { + tempURL, response, error in + + if (error != nil) { + self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error: " + error!.localizedDescription) + } else if (response != nil) { + + let status = (response as? HTTPURLResponse)!.statusCode + if (status < 300) { + + guard let fileURL = tempURL else { + DispatchQueue.main.async { [weak self] in self?.rstudio_enabled = true } + return + } + + do { + try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + try FileManager.default.moveItem(at: fileURL, to: dlfile) + self.notifyUser(title: "Success", subtitle: "RStudio Download", text: "Download of latest RStudio daily (" + dlurl.lastPathComponent + ") successful.") + NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") + NSWorkspace.shared.activateFileViewerSelecting([dlfile]) + } catch { + self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error: \(error)") + } + + } else { + self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error downloading latest RStudio daily. Status code: " + String(status)) + + } + } + + DispatchQueue.main.async { [weak self] in self?.rstudio_enabled = true } + + } + + task.resume() + } + + } catch { + self.notifyUser(title: "Action failed", subtitle: "RStudio Download", text: "Error downloading and saving latest RStudio daily.") + } + + } + +} diff --git a/RSwitch/swift/DownloadTarball.swift b/RSwitch/swift/DownloadTarball.swift new file mode 100644 index 0000000..8cbda4b --- /dev/null +++ b/RSwitch/swift/DownloadTarball.swift @@ -0,0 +1,74 @@ +// +// DownloadTarball.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +extension AppDelegate { + + // Download latest r-devel tarball + @objc func download_latest_tarball(_ sender: NSMenuItem?) { + + self.rdevel_enabled = false + + let dlurl = URL(string: "https://mac.r-project.org/el-capitan/R-devel/R-devel-el-capitan-sa-x86_64.tar.gz")! + let dldir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + var dlfile = dldir + + dlfile.appendPathComponent("R-devel-el-capitan-sa-x86_64.tar.gz") + + if (FileManager.default.fileExists(atPath: dlfile.relativePath)) { + + self.notifyUser(title: "Action required", subtitle: "r-devel Download", text: "R-devel tarball already exists. Please remove or rename it before downloading.") + + NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") + NSWorkspace.shared.activateFileViewerSelecting([dlfile]) + + self.rdevel_enabled = true + } else { + + let task = URLSession.shared.downloadTask(with: dlurl) { + tempURL, response, error in + + if (error != nil) { + self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error: " + error!.localizedDescription) + } else if (response != nil) { + + let status = (response as? HTTPURLResponse)!.statusCode + if (status < 300) { + + guard let fileURL = tempURL else { + DispatchQueue.main.async { [weak self] in self?.rdevel_enabled = true } + return + } + + do { + try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + try FileManager.default.moveItem(at: fileURL, to: dlfile) + self.notifyUser(title: "Success", subtitle: "r-devel Download", text: "Download of latest r-devel (" + dlurl.lastPathComponent + ") successful.") + NSWorkspace.shared.openFile(dldir.path, withApplication: "Finder") + NSWorkspace.shared.activateFileViewerSelecting([dlfile]) + } catch { + self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error: \(error)") + } + + } else { + self.notifyUser(title: "Action failed", subtitle: "r-devel Download", text: "Error downloading latest r-devel. Status code: " + String(status)) + } + } + + DispatchQueue.main.async { [weak self] in self?.rdevel_enabled = true } + + } + + task.resume() + } + + } + +} diff --git a/RSwitch/swift/HandleSwitch.swift b/RSwitch/swift/HandleSwitch.swift new file mode 100644 index 0000000..63ac002 --- /dev/null +++ b/RSwitch/swift/HandleSwitch.swift @@ -0,0 +1,43 @@ +// +// HandleSwitch.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +extension AppDelegate { + + struct app_dirs { + static let macos_r_framework = "/Library/Frameworks/R.framework/Versions" // Where the official R installs go + } + + // The core worker function. Receives the basename of the selected directory + // then removes the current alias and creates the new one. + @objc func handleSwitch(_ sender: NSMenuItem?) { + + let fm = FileManager.default; + let title = sender?.title + + do { + try fm.removeItem(atPath: app_dirs.macos_r_framework + "/" + "Current") + } catch { + self.notifyUser(title: "Action failed", text: "Failed to remove 'Current' alias" + app_dirs.macos_r_framework + "/" + "Current") + } + + do { + try fm.createSymbolicLink( + at: NSURL(fileURLWithPath: app_dirs.macos_r_framework + "/" + "Current") as URL, + withDestinationURL: NSURL(fileURLWithPath: app_dirs.macos_r_framework + "/" + title!) as URL + ) + self.notifyUser(title: "Success", text: "Current R version switched to " + title!) + } catch { + self.notifyUser(title: "Action failed", text: "Failed to create alias for " + app_dirs.macos_r_framework + "/" + title!) + } + + } + +} diff --git a/RSwitch/swift/HandleUpdate.swift b/RSwitch/swift/HandleUpdate.swift new file mode 100644 index 0000000..3063d6e --- /dev/null +++ b/RSwitch/swift/HandleUpdate.swift @@ -0,0 +1,34 @@ +// +// HandleUpdate.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +extension AppDelegate { + + @objc func checkForUpdate(_ sender: NSMenuItem?) { + + let url = URL(string: app_urls.version_check) + + do { + URLCache.shared.removeAllCachedResponses() + var version = try String.init(contentsOf: url!) + version = version.trimmingCharacters(in: .whitespacesAndNewlines) + if (version.isVersion(greaterThan: Bundle.main.releaseVersionNumber!)) { + let url = URL(string: app_urls.releases) + NSWorkspace.shared.open(url!) + } else { + self.notifyUser(title: "RSwitch", text: "You are running the latest version of RSwitch.") + } + } catch { + self.notifyUser(title: "Action failed", subtitle: "Update check", text: "Error: \(error)") + } + + } + +} diff --git a/RSwitch/swift/Menu.swift b/RSwitch/swift/Menu.swift new file mode 100644 index 0000000..e0be8d1 --- /dev/null +++ b/RSwitch/swift/Menu.swift @@ -0,0 +1,111 @@ +// +// Menu.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +extension AppDelegate: NSMenuDelegate { + + func menuWillOpen(_ menu: NSMenu) { + + if (menu != self.statusMenu) { return } + + // clear the menu + menu.removeAllItems() + + // add selection to open frameworks dir in Finder + menu.addItem(NSMenuItem(title: "Open R Frameworks Directory", action: #selector(openFrameworksDir), keyEquivalent: "" )) + menu.addItem(NSMenuItem.separator()) + + // populate installed versions + let fm = FileManager.default + var targetPath:String? = nil + + do { + + // gets a directory listing + let entries = try fm.contentsOfDirectory(atPath: app_dirs.macos_r_framework) + + // retrieves all versions (excludes hidden files and the Current alias + let versions = entries.sorted().filter { !($0.hasPrefix(".")) && !($0 == "Current") } + let hasCurrent = entries.filter { $0 == "Current" } + + // if there was a Current alias (prbly shld alert if not) + if (hasCurrent.count > 0) { + + // get where Current points to + let furl = NSURL(fileURLWithPath: app_dirs.macos_r_framework + "/" + "Current") + + if (furl.fileReferenceURL() != nil) { + do { + let fdat = try NSURL(resolvingAliasFileAt: furl as URL, options: []) + targetPath = fdat.lastPathComponent! + } catch { + targetPath = furl.path + } + } + + // populate menu items with all installed R versions, ensuring we + // put a checkbox next to the one that is Current + var i = 1 + for version in versions { + let keynum = (i < 10) ? String(i) : "" + let item = NSMenuItem(title: version, action: #selector(handleSwitch), keyEquivalent: keynum) + item.isEnabled = true + if (version == targetPath) { item.state = NSControl.StateValue.on } + item.representedObject = version + menu.addItem(item) + i = i + 1 + } + + } + + } catch { + quitAlert("Failed to list contents of R framework directory. You either do not have R installed or have incorrect permissions set on " + app_dirs.macos_r_framework) + } + + // Add items to download latest r-devel tarball and latest macOS daily + menu.addItem(NSMenuItem.separator()) + + let rdevelItem = NSMenuItem(title: NSLocalizedString("Download latest R-devel tarball", comment: "Download latest tarball item"), action: self.rdevel_enabled ? #selector(download_latest_tarball) : nil, keyEquivalent: "") + rdevelItem.isEnabled = self.rdevel_enabled + menu.addItem(rdevelItem) + + let rstudioItem = NSMenuItem(title: NSLocalizedString("Download latest RStudio daily build", comment: "Download latest RStudio item"), action: self.rstudio_enabled ? #selector(download_latest_rstudio) : nil, keyEquivalent: "") + rstudioItem.isEnabled = self.rstudio_enabled + menu.addItem(rstudioItem) + + + // Add items to open variosu R for macOS pages + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: NSLocalizedString("Open R for macOS Developers Page…", comment: "Open macOS Dev Page item"), action: #selector(browse_r_macos_dev_page), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: NSLocalizedString("Open R for macOS CRAN Page…", comment: "Open macOS CRAN Page item"), action: #selector(browse_r_macos_cran_page), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: NSLocalizedString("Open R-SIG-Mac Archives Page…", comment: "Open R-SIG-Mac Page item"), action: #selector(browse_r_sig_mac_page), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: NSLocalizedString("Open R Installation/Admin macOS Section…", comment: "Open R Install Page item"), action: #selector(browse_r_admin_macos_page), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: NSLocalizedString("Open RStudio macOS Dailies Page…", comment: "Open RStudio macOS Dailies Page item"), action: #selector(browse_rstudio_mac_dailies_page), keyEquivalent: "")) + + // Add launchers + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: NSLocalizedString("Launch R GUI", comment: "Launch R GUI item"), action: #selector(launchRApp), keyEquivalent: "")) + menu.addItem(NSMenuItem(title: NSLocalizedString("Launch RStudio", comment: "Launch RStudio item"), action: #selector(launchRStudio), keyEquivalent: "")) + + // Add a About item + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: NSLocalizedString("Check for update…", comment: "Check for update item"), action: #selector(checkForUpdate), keyEquivalent: "")) + + // Add a About item + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: NSLocalizedString("About RSwitch…", comment: "About menu item"), action: #selector(about), keyEquivalent: "")) + + // Add a Quit item + menu.addItem(NSMenuItem.separator()) + menu.addItem(quitItem) + + } + +} diff --git a/RSwitch/swift/Notify.swift b/RSwitch/swift/Notify.swift new file mode 100644 index 0000000..08bc0a6 --- /dev/null +++ b/RSwitch/swift/Notify.swift @@ -0,0 +1,33 @@ +// +// Notify.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +extension AppDelegate : NSUserNotificationCenterDelegate { + + func notifyUser(title: String? = nil, subtitle: String? = nil, text: String? = nil) -> Void { + + let notification = NSUserNotification() + + notification.title = title + notification.subtitle = subtitle + notification.informativeText = text + + notification.soundName = NSUserNotificationDefaultSoundName + + NSUserNotificationCenter.default.delegate = self + NSUserNotificationCenter.default.deliver(notification) + + } + + func userNotificationCenter(_ center: NSUserNotificationCenter, shouldPresent notification: NSUserNotification) -> Bool { + return true + } + +} diff --git a/RSwitch/String+Version.swift b/RSwitch/swift/String+Version.swift similarity index 100% rename from RSwitch/String+Version.swift rename to RSwitch/swift/String+Version.swift diff --git a/RSwitch/swift/Utils.swift b/RSwitch/swift/Utils.swift new file mode 100644 index 0000000..54c17d9 --- /dev/null +++ b/RSwitch/swift/Utils.swift @@ -0,0 +1,58 @@ +// +// Utils.swift +// RSwitch +// +// Created by hrbrmstr on 8/24/19. +// Copyright © 2019 Bob Rudis. All rights reserved. +// + +import Foundation +import Cocoa + +public func browse(_ urlString : String) { + let url = URL(string: urlString)! + NSWorkspace.shared.open(url) +} + + +extension AppDelegate { + + struct app_urls { + static let mac_r_project = "https://mac.r-project.org/" + static let macos_cran = "https://cran.rstudio.org/bin/macosx/" + static let r_sig_mac = "https://stat.ethz.ch/pipermail/r-sig-mac/" + static let rstudio_dailies = "https://dailies.rstudio.com/rstudio/oss/mac/" + static let latest_rstudio_dailies = "https://www.rstudio.org/download/latest/daily/desktop/mac/RStudio-latest.dmg" + static let browse_r_admin_macos = "https://cran.rstudio.org/doc/manuals/R-admin.html#Installing-R-under-macOS" + static let version_check = "https://rud.is/rswitch/releases/current-version.txt" + static let releases = "https://git.rud.is/hrbrmstr/RSwitch/releases" + } + + // browse macOS dev page + @objc func browse_r_macos_dev_page(_ sender: NSMenuItem?) { browse(app_urls.mac_r_project) } + + // browse macOS dev page + @objc func browse_r_macos_cran_page(_ sender: NSMenuItem?) { browse(app_urls.macos_cran) } + + // browse macOS dev page + @objc func browse_r_sig_mac_page(_ sender: NSMenuItem?) { browse(app_urls.r_sig_mac) } + + // browse RStudio macOS Dailies + @objc func browse_rstudio_mac_dailies_page(_ sender: NSMenuItem?) { browse(app_urls.rstudio_dailies) } + + // browse R Install/Admin macOS section + @objc func browse_r_admin_macos_page(_ sender: NSMenuItem?) { browse(app_urls.browse_r_admin_macos) } + + // Show about dialog + @objc func about(_ sender: NSMenuItem?) { abtController.showWindow(self) } + + // Show the framework dir in a new Finder window + @objc func openFrameworksDir(_ sender: NSMenuItem?) { NSWorkspace.shared.openFile(app_dirs.macos_r_framework, withApplication: "Finder") } + + // Launch RStudio + @objc func launchRStudio(_ sender: NSMenuItem?) { NSWorkspace.shared.launchApplication("RStudio.app") } + + // Launch R.app + @objc func launchRApp(_ sender: NSMenuItem?) { NSWorkspace.shared.launchApplication("R.app") } + +} diff --git a/RSwitch/ViewController.swift b/RSwitch/swift/ViewController.swift similarity index 100% rename from RSwitch/ViewController.swift rename to RSwitch/swift/ViewController.swift