diff --git a/Source/AutoUpdater.cpp b/Source/AutoUpdater.cpp new file mode 100644 index 000000000..1fb975aff --- /dev/null +++ b/Source/AutoUpdater.cpp @@ -0,0 +1,527 @@ +/* + ------------------------------------------------------------------ + + This file is part of the Open Ephys GUI + Copyright (C) 2023 Open Ephys + + ------------------------------------------------------------------ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + Reference : https://github.com/juce-framework/JUCE/blob/6.0.8/extras/Projucer/Source/Application/jucer_AutoUpdater.cpp + +*/ + +#include "AutoUpdater.h" +#include "CoreServices.h" +#include "MainWindow.h" +#ifdef _WIN32 +#include +#include +#endif + +//============================================================================== +LatestVersionCheckerAndUpdater::LatestVersionCheckerAndUpdater() + : Thread ("VersionChecker") + , mainWindow(nullptr) +{ +} + +LatestVersionCheckerAndUpdater::~LatestVersionCheckerAndUpdater() +{ + stopThread (6000); + clearSingletonInstance(); +} + +void LatestVersionCheckerAndUpdater::checkForNewVersion (bool background, MainWindow* mw) +{ + if (! isThreadRunning()) + { + backgroundCheck = background; + mainWindow = mw; + startThread (3); + } +} + +//============================================================================== +void LatestVersionCheckerAndUpdater::run() +{ + LOGC("Checking for a new version...."); + URL latestVersionURL ("https://api.github.com/repos/open-ephys/plugin-GUI/releases/latest"); + + std::unique_ptr inStream (latestVersionURL.createInputStream (URL::InputStreamOptions (URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs (5000))); + const String commErr = "Failed to communicate with the Open Ephys update server.\n" + "Please try again in a few minutes.\n\n" + "If this problem persists you can download the latest version of Open Ephys GUI from open-ephys.org/gui"; + + if (inStream == nullptr) + { + if (! backgroundCheck) + AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + "Update Server Communication Error", + commErr); + + return; + } + + auto content = inStream->readEntireStreamAsString(); + auto latestReleaseDetails = JSON::parse (content); + + auto* json = latestReleaseDetails.getDynamicObject(); + + if (json == nullptr) + { + if (! backgroundCheck) + AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + "Update Server Communication Error", + commErr); + + return; + } + + auto versionString = json->getProperty ("tag_name").toString(); + + if (versionString.isEmpty()) + return; + + auto* assets = json->getProperty ("assets").getArray(); + + if (assets == nullptr) + return; + + auto releaseNotes = json->getProperty ("body").toString(); + + std::vector parsedAssets; + + for (auto& asset : *assets) + { + if (auto* assetJson = asset.getDynamicObject()) + { + parsedAssets.push_back ({ assetJson->getProperty ("name").toString(), + assetJson->getProperty ("url").toString(), + (int)assetJson->getProperty("size")}); + jassert (parsedAssets.back().name.isNotEmpty()); + jassert (parsedAssets.back().url.isNotEmpty()); + jassert (parsedAssets.back().size != 0); + + } + else + { + jassertfalse; + } + } + + String latestVer = versionString.substring(1); + + + if (latestVer.compareNatural(CoreServices::getGUIVersion()) <= 0) + { + if (! backgroundCheck) + AlertWindow::showMessageBoxAsync (AlertWindow::InfoIcon, + "No New Version Available", + "Your GUI version is up to date."); + return; + } + + auto osString = [] + { + #if JUCE_MAC + return "mac"; + #elif JUCE_WINDOWS + return "windows"; + #elif JUCE_LINUX + return "linux"; + #else + jassertfalse; + return "Unknown"; + #endif + }(); + + String requiredFilename ("open-ephys-" + versionString + "-" + osString + ".zip"); + +#if JUCE_WINDOWS + File exeDir = File::getSpecialLocation(File::SpecialLocationType::currentExecutableFile).getParentDirectory(); + if(exeDir.findChildFiles(File::findFiles, false, "unins*").size() > 0) + { + requiredFilename = "Install-Open-Ephys-GUI-" + versionString + ".exe"; + } +#elif JUCE_LINUX + File exeDir = File::getSpecialLocation(File::SpecialLocationType::currentExecutableFile).getParentDirectory(); + if(exeDir.getFullPathName().contains("/usr/local/bin")) + { + requiredFilename = "open-ephys-gui-" + versionString + ".deb"; + } +#elif JUCE_MAC + File exeDir = File::getSpecialLocation(File::SpecialLocationType::currentApplicationFile).getParentDirectory(); + File globalAppDir = File::getSpecialLocation(File::SpecialLocationType::globalApplicationsDirectory); + if(exeDir.getFullPathName().contains(globalAppDir.getFullPathName())) + { + requiredFilename = "Open_Ephys_GUI_" + versionString + ".dmg"; + } +#endif + + for (auto& asset : parsedAssets) + { + if (asset.name == requiredFilename) + { + + MessageManager::callAsync ([this, versionString, releaseNotes, asset] + { + askUserAboutNewVersion (versionString, releaseNotes, asset); + }); + + return; + } + } + + if (! backgroundCheck) + AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + "Failed to find any new downloads", + "Please try again in a few minutes."); +} + +//============================================================================== +class UpdateDialog : public Component +{ +public: + UpdateDialog (const String& newVersion, const String& releaseNotes, bool automaticVerCheck) + { + titleLabel.setText ("Open Ephys GUI version " + newVersion, dontSendNotification); + titleLabel.setFont (Font("Fira Sans", "SemiBold", 18.0f)); + titleLabel.setJustificationType (Justification::centred); + addAndMakeVisible (titleLabel); + + contentLabel.setText ("A new version of Open Ephys GUI is available - would you like to download it?", dontSendNotification); + contentLabel.setFont (Font("Fira Sans", "Regular", 16.0f)); + contentLabel.setJustificationType (Justification::topLeft); + contentLabel.setMinimumHorizontalScale(1.0); + addAndMakeVisible (contentLabel); + + releaseNotesEditor.setMultiLine (true); + releaseNotesEditor.setReadOnly (true); + releaseNotesEditor.setText (releaseNotes); + addAndMakeVisible (releaseNotesEditor); + + addAndMakeVisible (downloadButton); + downloadButton.onClick = [this] { exitModalStateWithResult (1); }; + + addAndMakeVisible (cancelButton); + cancelButton.onClick = [this] + { + if(dontAskAgainButton.getToggleState()) + exitModalStateWithResult (-1); + else + exitModalStateWithResult(0); + }; + + dontAskAgainButton.setToggleState (!automaticVerCheck, dontSendNotification); + addAndMakeVisible (dontAskAgainButton); + +#if JUCE_MAC + File iconDir = File::getSpecialLocation(File::currentApplicationFile).getChildFile("Contents/Resources"); +#else + File iconDir = File::getSpecialLocation(File::currentApplicationFile).getParentDirectory(); +#endif + juceIcon = Drawable::createFromImageFile(iconDir.getChildFile("icon-small.png")); + lookAndFeelChanged(); + + setSize (640, 480); + } + + void resized() override + { + auto b = getLocalBounds().reduced (10); + + auto topSlice = b.removeFromTop (juceIconBounds.getHeight()) + .withTrimmedLeft (juceIconBounds.getWidth()); + + titleLabel.setBounds (topSlice.removeFromTop (25)); + topSlice.removeFromTop (5); + contentLabel.setBounds (topSlice.removeFromTop (25)); + + auto buttonBounds = b.removeFromBottom (60); + buttonBounds.removeFromBottom (25); + downloadButton.setBounds (buttonBounds.removeFromLeft (buttonBounds.getWidth() / 2).reduced (20, 0)); + cancelButton.setBounds (buttonBounds.reduced (20, 0)); + dontAskAgainButton.setBounds (cancelButton.getBounds().withY (cancelButton.getBottom() + 5).withHeight (20)); + + releaseNotesEditor.setBounds (b.reduced (0, 10)); + } + + void paint (Graphics& g) override + { + g.fillAll (Colours::lightgrey); + + if (juceIcon != nullptr) + juceIcon->drawWithin (g, juceIconBounds.toFloat(), + RectanglePlacement::stretchToFit, 1.0f); + } + + static std::unique_ptr launchDialog (const String& newVersionString, + const String& releaseNotes, + bool automaticVerCheck) + { + DialogWindow::LaunchOptions options; + + options.dialogTitle = "Download Open Ephys GUI version " + newVersionString + "?"; + options.resizable = false; + + auto* content = new UpdateDialog (newVersionString, releaseNotes, automaticVerCheck); + options.content.set (content, true); + + std::unique_ptr dialog (options.create()); + + content->setParentWindow (dialog.get()); + dialog->enterModalState (true, nullptr, true); + + return dialog; + } + +private: + void lookAndFeelChanged() override + { + cancelButton.setColour (TextButton::buttonColourId, Colours::crimson); + releaseNotesEditor.applyFontToAllText (Font("Fira Sans", "Regular", 16.0f)); + } + + void setParentWindow (DialogWindow* parent) + { + parentWindow = parent; + } + + void exitModalStateWithResult (int result) + { + if (parentWindow != nullptr) + parentWindow->exitModalState (result); + } + + Label titleLabel, contentLabel, releaseNotesLabel; + TextEditor releaseNotesEditor; + TextButton downloadButton { "Download" }, cancelButton { "Cancel" }; + ToggleButton dontAskAgainButton { "Don't ask again" }; + std::unique_ptr juceIcon; + juce::Rectangle juceIconBounds { 10, 10, 64, 64 }; + + DialogWindow* parentWindow = nullptr; +}; + +void LatestVersionCheckerAndUpdater::askUserForLocationToDownload (const Asset& asset) +{ + FileChooser chooser ("Please select the location into which you would like to install the new version", + { File::getSpecialLocation(File::userDesktopDirectory) }, + "*.exe;*.zip"); + + if (chooser.browseForDirectory()) + { + auto targetFolder = chooser.getResult(); + if (targetFolder == File{}) + return; + + File targetFile = targetFolder.getChildFile(asset.name).getNonexistentSibling(); + + downloadAndInstall (asset, targetFile); + } +} + +void LatestVersionCheckerAndUpdater::askUserAboutNewVersion (const String& newVersionString, + const String& releaseNotes, + const Asset& asset) +{ + dialogWindow = UpdateDialog::launchDialog (newVersionString, + releaseNotes, + mainWindow->automaticVersionChecking); + + if (auto* mm = ModalComponentManager::getInstance()) + { + mm->attachCallback (dialogWindow.get(), + ModalCallbackFunction::create ([this, asset] (int result) + { + if (result == 1) + askUserForLocationToDownload (asset); + else if(result == -1) + mainWindow->automaticVersionChecking = false; + else if(result == 0) + mainWindow->automaticVersionChecking = true; + + dialogWindow.reset(); + })); + } +} + +//============================================================================== +class DownloadThread : private ThreadWithProgressWindow +{ +public: + DownloadThread (const LatestVersionCheckerAndUpdater::Asset& a, + const File& t, + std::function&& cb) + : ThreadWithProgressWindow ("Downloading New Version", true, true), + asset (a), targetFile (t), completionCallback (std::move (cb)) + { + launchThread (3); + } + +private: + void run() override + { + setProgress (0.0); + + auto result = download (targetFile); + + if (result.failed()) + { + AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon, + "Downloading Failed", + result.getErrorMessage()); + } + else + { + setProgress (-1.0); + MessageManager::callAsync (completionCallback); + } + } + + Result download (File& dest) + { + setStatusMessage ("Downloading..."); + + int statusCode = 0; + URL downloadUrl (asset.url); + StringPairArray responseHeaders; + + auto inStream = downloadUrl.createInputStream (URL::InputStreamOptions (URL::ParameterHandling::inAddress) + .withExtraHeaders ("Accept: application/octet-stream") + .withConnectionTimeoutMs (5000) + .withResponseHeaders (&responseHeaders) + .withStatusCode (&statusCode) + .withNumRedirectsToFollow (1)); + + if (inStream != nullptr && statusCode == 200) + { + int64 total = 0; + + //Use the Url's input stream and write it to a file using output stream + std::unique_ptr out = dest.createOutputStream(); + + for (;;) + { + if (threadShouldExit()) + return Result::fail ("Cancelled"); + + + + auto written = out->writeFromInputStream(*inStream, 8192); + + if (written == 0) + break; + + total += written; + + setProgress((double)total / (double)asset.size); + + setStatusMessage ("Downloading... " + + File::descriptionOfSizeInBytes (total) + + " / " + + File::descriptionOfSizeInBytes (asset.size)); + } + + out->flush(); + return Result::ok(); + } + + return Result::fail ("Failed to download from: " + asset.url); + } + + const LatestVersionCheckerAndUpdater::Asset asset; + File targetFile; + std::function completionCallback; +}; + +static void runInstaller (const File& targetFile) +{ + bool runInstaller = AlertWindow::showOkCancelBox(AlertWindow::WarningIcon, + "Quit Open Ephys GUI?", + "To run the installer, the current instance of GUI needs to be closed." + "\nAre you sure you want to continue?", + "Yes", "No"); + + if(runInstaller) + { + #if JUCE_WINDOWS + if (targetFile.existsAsFile()) + { + auto returnCode = ShellExecute(NULL, (LPCSTR)"runas", targetFile.getFullPathName().toRawUTF8(), NULL, NULL, SW_SHOW); + + if((int)returnCode > 31) + JUCEApplication::getInstance()->systemRequestedQuit(); + else + LOGE("Failed to run the installer: ", GetLastError()); + } + #endif + } +} + +void LatestVersionCheckerAndUpdater::downloadAndInstall (const Asset& asset, const File& targetFile) +{ +#if JUCE_WINDOWS + File exeDir = File::getSpecialLocation( + File::SpecialLocationType::currentExecutableFile).getParentDirectory(); + + if(exeDir.findChildFiles(File::findFiles, false, "unins*").size() > 0) + { + downloader.reset (new DownloadThread (asset, targetFile, + [this, targetFile] + { + downloader.reset(); + runInstaller(targetFile); + + })); + } + else +#endif + { + String msgBoxString = String(); + + if(targetFile.getFileExtension().equalsIgnoreCase(".zip")) + { + msgBoxString = "Please extract the zip file located at: \n" + + targetFile.getFullPathName().quoted() + + "\nto your desired location and then run the updated version from there. " + "You can also overwrite the current installation after quitting the current instance."; + + } + else + { + msgBoxString = "Please quit the GUI first, then launch the installer file located at: \n" + + targetFile.getFullPathName().quoted() + + "\nand follow the steps to finish updating the GUI."; + } + + + downloader.reset (new DownloadThread (asset, targetFile, + [this, msgBoxString] + { + downloader.reset(); + + AlertWindow::showMessageBoxAsync + (AlertWindow::InfoIcon, + "Download successful!", + msgBoxString); + + })); + } +} + +//============================================================================== +JUCE_IMPLEMENT_SINGLETON (LatestVersionCheckerAndUpdater) diff --git a/Source/AutoUpdater.h b/Source/AutoUpdater.h new file mode 100644 index 000000000..cdd974939 --- /dev/null +++ b/Source/AutoUpdater.h @@ -0,0 +1,64 @@ +/* + ------------------------------------------------------------------ + + This file is part of the Open Ephys GUI + Copyright (C) 2023 Open Ephys + + ------------------------------------------------------------------ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + Reference : https://github.com/juce-framework/JUCE/blob/6.0.8/extras/Projucer/Source/Application/jucer_AutoUpdater.h + +*/ + +#pragma once + +#include "../JuceLibraryCode/JuceHeader.h" + +class MainWindow; +class DownloadThread; + +class LatestVersionCheckerAndUpdater : public DeletedAtShutdown, + private Thread +{ +public: + LatestVersionCheckerAndUpdater(); + ~LatestVersionCheckerAndUpdater() override; + + struct Asset + { + const String name; + const String url; + const int size; + }; + + void checkForNewVersion (bool isBackgroundCheck, MainWindow* mw); + + //============================================================================== + JUCE_DECLARE_SINGLETON_SINGLETHREADED_MINIMAL (LatestVersionCheckerAndUpdater) + +private: + //============================================================================== + void run() override; + void askUserAboutNewVersion (const String&, const String&, const Asset& asset); + void askUserForLocationToDownload (const Asset& asset); + void downloadAndInstall (const Asset& asset, const File& targetFile); + + //============================================================================== + bool backgroundCheck = false; + MainWindow* mainWindow; + + std::unique_ptr downloader; + std::unique_ptr dialogWindow; +}; diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 543150a83..4b3382516 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -4,6 +4,8 @@ add_sources(open-ephys AccessClass.h AccessClass.cpp + AutoUpdater.cpp + AutoUpdater.h CoreServices.h CoreServices.cpp MainWindow.h diff --git a/Source/MainWindow.cpp b/Source/MainWindow.cpp index ebf6bc2da..a24d31b93 100644 --- a/Source/MainWindow.cpp +++ b/Source/MainWindow.cpp @@ -25,6 +25,7 @@ #include "Utils/OpenEphysHttpServer.h" #include "UI/UIComponent.h" #include "UI/EditorViewport.h" +#include "AutoUpdater.h" #include @@ -59,6 +60,7 @@ MainWindow::MainWindow(const File& fileToLoad) shouldReloadOnStartup = true; shouldEnableHttpServer = true; openDefaultConfigWindow = false; + automaticVersionChecking = true; // Create ProcessorGraph and AudioComponent, and connect them. // Callbacks will be set by the play button in the control panel @@ -160,6 +162,10 @@ MainWindow::MainWindow(const File& fileToLoad) disableHttpServer(); } +#ifdef NDEBUG + if(automaticVersionChecking) + LatestVersionCheckerAndUpdater::getInstance()->checkForNewVersion (true, this); +#endif } MainWindow::~MainWindow() @@ -267,6 +273,7 @@ void MainWindow::saveWindowBounds() xml->setAttribute("version", JUCEApplication::getInstance()->getApplicationVersion()); xml->setAttribute("shouldReloadOnStartup", shouldReloadOnStartup); xml->setAttribute("shouldEnableHttpServer", shouldEnableHttpServer); + xml->setAttribute("automaticVersionChecking", automaticVersionChecking); XmlElement* bounds = new XmlElement("BOUNDS"); bounds->setAttribute("x",getScreenX()); @@ -330,6 +337,7 @@ void MainWindow::loadWindowBounds() shouldReloadOnStartup = xml->getBoolAttribute("shouldReloadOnStartup", false); shouldEnableHttpServer = xml->getBoolAttribute("shouldEnableHttpServer", false); + automaticVersionChecking = xml->getBoolAttribute("automaticVersionChecking", true); for (auto* e : xml->getChildIterator()) { diff --git a/Source/MainWindow.h b/Source/MainWindow.h index 483952026..551b0bf30 100644 --- a/Source/MainWindow.h +++ b/Source/MainWindow.h @@ -68,8 +68,12 @@ class MainWindow : public DocumentWindow /** Determines whether the ProcessorGraph http server is enabled. */ bool shouldEnableHttpServer; + /** Determines whether the default config selection window needs to open on startup. */ bool openDefaultConfigWindow; + /** Determines whether the Auto Updater needs to run on startup. */ + bool automaticVersionChecking; + /** Ends the process() callbacks and disables all processors.*/ void shutDownGUI(); diff --git a/Source/UI/UIComponent.cpp b/Source/UI/UIComponent.cpp index 5e33a45a7..d7cfa55d0 100755 --- a/Source/UI/UIComponent.cpp +++ b/Source/UI/UIComponent.cpp @@ -36,6 +36,7 @@ #include "../Processors/ProcessorGraph/ProcessorGraph.h" #include "../Audio/AudioComponent.h" #include "../MainWindow.h" +#include "../AutoUpdater.h" UIComponent::UIComponent(MainWindow* mainWindow_, ProcessorGraph* pgraph, AudioComponent* audio_) : mainWindow(mainWindow_), processorGraph(pgraph), audio(audio_), messageCenterIsCollapsed(true) @@ -476,6 +477,7 @@ PopupMenu UIComponent::getMenuForIndex(int menuIndex, const String& menuName) else if (menuIndex == 3) { menu.addCommandItem(commandManager, showHelp); + menu.addCommandItem(commandManager, checkForUpdates); } return menu; @@ -518,6 +520,7 @@ void UIComponent::getAllCommands(Array & commands) setClockModeDefault, setClockModeHHMMSS, showHelp, + checkForUpdates, resizeWindow, openPluginInstaller, openDefaultConfigWindow @@ -651,6 +654,11 @@ void UIComponent::getCommandInfo(CommandID commandID, ApplicationCommandInfo& re result.setActive(true); break; + case checkForUpdates: + result.setInfo("Check for updates", "Checks if a newer version of the GUI is available", "General", 0); + result.setActive(true); + break; + case resizeWindow: result.setInfo("Reset window bounds", "Reset window bounds", "General", 0); break; @@ -852,6 +860,12 @@ bool UIComponent::perform(const InvocationInfo& info) url.launchInDefaultBrowser(); break; } + + case checkForUpdates: + { + LatestVersionCheckerAndUpdater::getInstance()->checkForNewVersion (false, mainWindow); + break; + } case toggleProcessorList: processorList->toggleState(); diff --git a/Source/UI/UIComponent.h b/Source/UI/UIComponent.h index 7dca1d685..c21399a52 100755 --- a/Source/UI/UIComponent.h +++ b/Source/UI/UIComponent.h @@ -206,6 +206,7 @@ class UIComponent : public Component, setClockModeHHMMSS = 0x2112, toggleHttpServer = 0x4001, showHelp = 0x2011, + checkForUpdates = 0x2022, resizeWindow = 0x2012, reloadOnStartup = 0x2013, saveSignalChainAs = 0x2014,