From b989ac5c921fe2ecb49b831c20935ffe598f805a Mon Sep 17 00:00:00 2001 From: Robin Davies Date: Sun, 25 Aug 2024 00:20:48 -0400 Subject: [PATCH] Update dialog --- .vscode/launch.json | 2 +- CMakeLists.txt | 4 +- PiPedalCommon/src/SysExec.cpp | 39 ++ PiPedalCommon/src/include/SysExec.hpp | 30 +- PiPedalCommon/src/include/json_variant.hpp | 100 ++-- PiPedalCommon/src/json_variant.cpp | 5 + react/CMakeLists.txt | 1 + react/public/var/config.json | 2 +- react/src/AboutDialog.tsx | 6 +- react/src/AndroidHost.tsx | 8 +- react/src/AppThemed.tsx | 63 ++- react/src/PiPedalModel.tsx | 442 ++++++++++----- react/src/PiPedalSocket.tsx | 8 +- react/src/SettingsDialog.tsx | 18 + react/src/UpdateDialog.tsx | 284 ++++++++++ react/src/Updater.tsx | 119 ++++ src/CMakeLists.txt | 11 +- src/PiPedalModel.cpp | 53 +- src/PiPedalModel.hpp | 13 +- src/PiPedalSocket.cpp | 31 +- src/Updater.cpp | 609 +++++++++++++++++++++ src/Updater.hpp | 145 +++++ src/UpdaterTest.cpp | 52 ++ src/jsonTest.cpp | 31 +- test/UriTest.cpp | 2 +- 25 files changed, 1857 insertions(+), 221 deletions(-) create mode 100644 react/src/UpdateDialog.tsx create mode 100644 react/src/Updater.tsx create mode 100644 src/Updater.cpp create mode 100644 src/Updater.hpp create mode 100644 src/UpdaterTest.cpp diff --git a/.vscode/launch.json b/.vscode/launch.json index d459cd3..cfc3802 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -112,7 +112,7 @@ //"[json_variants]" // subtest of your choice, or none to run all of the tests. //"[inverting_mutex_test]" // "[utf8_to_utf32]" - "[wifi_channels_test]" + "[updater]" ], "stopAtEntry": false, diff --git a/CMakeLists.txt b/CMakeLists.txt index 8796e44..e474c95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,8 +4,8 @@ project(pipedal DESCRIPTION "PiPedal Guitar Effect Pedal For Raspberry Pi" HOMEPAGE_URL "https://rerdavies.github.io/pipedal" ) -set (DISPLAY_VERSION "v1.2.41") - +set (DISPLAY_VERSION "PiPedal v1.2.41-Release") +set (PACKAGE_ARCHITECTURE "arm64") set (CMAKE_INSTALL_PREFIX "/usr/") include(CTest) diff --git a/PiPedalCommon/src/SysExec.cpp b/PiPedalCommon/src/SysExec.cpp index e97cb41..8506b34 100644 --- a/PiPedalCommon/src/SysExec.cpp +++ b/PiPedalCommon/src/SysExec.cpp @@ -267,3 +267,42 @@ int pipedal::sysExecWait(ProcessId pid_) return exitStatus; } + +SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::string &args) +{ + namespace fs = std::filesystem; + fs::path fullPath = findOnSystemPath(program); + if (!fs::exists(fullPath)) + { + throw std::runtime_error(SS("Path does not exist. " << fullPath)); + } + std::stringstream s; + s << fullPath.c_str() << " " << args << " 2>&1"; + + std::string fullCommand = s.str(); + FILE *output = popen(fullCommand.c_str(), "r"); + if (output) + { + char buffer[512]; + std::stringstream ssOutput; + + while (!feof(output)) + { + size_t nRead = fread(buffer, sizeof(char),sizeof(buffer), output); + ssOutput.write(buffer,nRead); + if (nRead == 0) + break; + + } + int rc = pclose(output); + SysExecOutput result + { + .exitCode = rc, + .output = ssOutput.str() + }; + return result; + } else { + throw std::runtime_error(SS("Failed to execute command. " << fullCommand )); + } + +} diff --git a/PiPedalCommon/src/include/SysExec.hpp b/PiPedalCommon/src/include/SysExec.hpp index 1bacde5..031b90d 100644 --- a/PiPedalCommon/src/include/SysExec.hpp +++ b/PiPedalCommon/src/include/SysExec.hpp @@ -20,20 +20,28 @@ #pragma once #include -namespace pipedal { -// exec a command, returning the actual exit code (unlike execXX() or system() ) -int sysExec(const char*szCommand); +namespace pipedal +{ + // exec a command, returning the actual exit code (unlike execXX() or system() ) + int sysExec(const char *szCommand); -// execute a command, suppressing output. -int silentSysExec(const char *szCommand); + // execute a command, suppressing output. + int silentSysExec(const char *szCommand); + struct SysExecOutput + { + int exitCode; + std::string output; + }; -using ProcessId = int64_t; // platform-agnostic wrapper for pid_t; -// Returns a pid or -1 on errror. -ProcessId sysExecAsync(const std::string&command); + SysExecOutput sysExecForOutput(const std::string& command, const std::string&args); -void sysExecTerminate(ProcessId pid_,int termTimeoutMs = 1000,int killTimeoutMs = 500 ); // returns the process's exit status. -int sysExecWait(ProcessId pid); + using ProcessId = int64_t; // platform-agnostic wrapper for pid_t; + // Returns a pid or -1 on errror. + ProcessId sysExecAsync(const std::string &command); -std::string getSelfExePath(); + void sysExecTerminate(ProcessId pid_, int termTimeoutMs = 1000, int killTimeoutMs = 500); // returns the process's exit status. + int sysExecWait(ProcessId pid); + + std::string getSelfExePath(); } \ No newline at end of file diff --git a/PiPedalCommon/src/include/json_variant.hpp b/PiPedalCommon/src/include/json_variant.hpp index 88d43e0..c972ace 100644 --- a/PiPedalCommon/src/include/json_variant.hpp +++ b/PiPedalCommon/src/include/json_variant.hpp @@ -68,6 +68,7 @@ namespace pipedal public: ~json_variant(); json_variant(); + json_variant(json_reader &reader); json_variant(json_variant &&); json_variant(const json_variant &); @@ -81,9 +82,9 @@ namespace pipedal json_variant(const std::shared_ptr &value); json_variant(json_array &&array); json_variant(json_object &&object); - json_variant(const char*sz); + json_variant(const char *sz); - json_variant(const void*) = delete; // do NOT allow implicit conversion of pointers to bool + json_variant(const void *) = delete; // do NOT allow implicit conversion of pointers to bool json_variant &operator=(json_variant &&value); json_variant &operator=(const json_variant &value); @@ -94,9 +95,9 @@ namespace pipedal json_variant &operator=(json_object &&value); json_variant &operator=(json_array &&value); - json_variant &operator=(const char*sz) { return (*this) = std::string(sz); } + json_variant &operator=(const char *sz) { return (*this) = std::string(sz); } - json_variant &operator=(void*) = delete; // do NOT allow implicit conversion of pointers to bool + json_variant &operator=(void *) = delete; // do NOT allow implicit conversion of pointers to bool void require_type(ContentType content_type) const { @@ -145,6 +146,22 @@ namespace pipedal return content.double_value; } + int8_t as_int8() const { return (int8_t)as_number(); } + + uint8_t as_uint8() const { return (uint8_t)as_number(); } + + int16_t as_int16() const { return (int16_t)as_number(); } + + uint16_t as_uint16() const { return (uint16_t)as_number(); } + + int32_t as_int32() const { return (int32_t)as_number(); } + + uint32_t as_uint32() const { return (uint32_t)as_number(); } + + int64_t as_int64() const { return (int64_t)as_number(); } + + uint64_t as_uint64() const { return (uint64_t)as_number(); } + const std::string &as_string() const; std::string &as_string(); @@ -154,9 +171,6 @@ namespace pipedal const std::shared_ptr &as_array() const; std::shared_ptr &as_array(); - template - U &as() { static_assert("Invalid type."); } - // convenience methods for object and array manipulation. static json_variant make_object(); static json_variant make_array(); @@ -179,6 +193,7 @@ namespace pipedal bool operator!=(const json_variant &other) const; std::string to_string() const; + private: void free(); void write_double_value(json_writer &writer, double value) const; @@ -212,12 +227,12 @@ namespace pipedal class json_array : public JsonSerializable { private: - json_array(const json_array&) { } // deleted. + json_array(const json_array &) {} // deleted. public: using ptr = std::shared_ptr; json_array() { ++allocation_count_; } - json_array(json_array&&other); + json_array(json_array &&other); ~json_array() { --allocation_count_; } json_variant &at(size_t index); @@ -247,7 +262,7 @@ namespace pipedal } using iterator = std::vector::iterator; using const_iterator = std::vector::const_iterator; - + iterator begin() { return values.begin(); } iterator end() { return values.end(); } const_iterator begin() const { return values.begin(); } @@ -266,13 +281,13 @@ namespace pipedal class json_object : public JsonSerializable { private: - json_object(const json_object&) { } // deleted. + json_object(const json_object &) {} // deleted. public: using ptr = std::shared_ptr; - json_object() { ++ allocation_count_;} - json_object(json_object&&other); - ~json_object() { --allocation_count_;} + json_object() { ++allocation_count_; } + json_object(json_object &&other); + ~json_object() { --allocation_count_; } size_t size() const { return values.size(); } json_variant &at(const std::string &index); @@ -285,25 +300,24 @@ namespace pipedal bool operator!=(const json_object &other) const { return (!((*this) == other)); } bool contains(const std::string &index) const; - - using values_t = std::vector< std::pair >; + using values_t = std::vector>; using iterator = values_t::iterator; using const_iterator = values_t::const_iterator; - + iterator begin() { return values.begin(); } iterator end() { return values.end(); } const_iterator begin() const { return values.begin(); } const_iterator end() const { return values.end(); } - iterator find(const std::string& key); - const_iterator find(const std::string& key) const; - + iterator find(const std::string &key); + const_iterator find(const std::string &key) const; // strictly for testing purposes. Not thread-safe. - static int64_t allocation_count() + static int64_t allocation_count() { return allocation_count_; } + private: virtual void read_json(json_reader &reader); virtual void write_json(json_writer &writer) const; @@ -317,27 +331,6 @@ namespace pipedal inline std::string &json_variant::memString() { return *(std::string *)content.mem; } inline const std::string &json_variant::memString() const { return *(const std::string *)content.mem; } - template <> - inline json_null &json_variant::as() { return as_null(); } - - template <> - inline bool &json_variant::as() { return as_bool(); } - - template <> - inline double &json_variant::as() { return as_number(); } - - template <> - inline std::string &json_variant::as() { return as_string(); } - - template <> - inline std::shared_ptr &json_variant::as>() { return as_object(); } - - template <> - inline std::shared_ptr &json_variant::as>() { return as_array(); } - - template <> - inline json_variant &json_variant::as() { return *this; } - inline json_variant::object_ptr &json_variant::memObject() { return *(object_ptr *)content.mem; @@ -525,22 +518,23 @@ namespace pipedal return !(*this == other); } - // Holds a string but is json_read and json_written as an unqoted json object. - class raw_json_string: public JsonSerializable { - public: - raw_json_string() { } - raw_json_string(const std::string &value) : value(value) {} - const std::string& as_string() const { return value; } + class raw_json_string : public JsonSerializable + { + public: + raw_json_string() {} + raw_json_string(const std::string &value) : value(value) {} + const std::string &as_string() const { return value; } - void Set(const std::string&value) { this->value = value; } + void Set(const std::string &value) { this->value = value; } - private: - - virtual void write_json(json_writer &writer) const { + private: + virtual void write_json(json_writer &writer) const + { writer.write_raw(value.c_str()); } - virtual void read_json(json_reader &reader) { + virtual void read_json(json_reader &reader) + { throw std::logic_error("Not implemented."); } diff --git a/PiPedalCommon/src/json_variant.cpp b/PiPedalCommon/src/json_variant.cpp index 8c1b3ae..aa1b385 100644 --- a/PiPedalCommon/src/json_variant.cpp +++ b/PiPedalCommon/src/json_variant.cpp @@ -552,6 +552,11 @@ json_variant::json_variant(const char*sz) } +json_variant::json_variant(json_reader&reader) +{ + this->read_json(reader); +} + /*static*/ json_null json_null::instance; diff --git a/react/CMakeLists.txt b/react/CMakeLists.txt index 1f2d20b..862f75d 100644 --- a/react/CMakeLists.txt +++ b/react/CMakeLists.txt @@ -21,6 +21,7 @@ add_custom_command( WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}/react DEPENDS + src/Updater.tsx src/OkCancelDialog.tsx src/PluginControl.tsx src/svg/ic_save_bank_as.svg diff --git a/react/public/var/config.json b/react/public/var/config.json index 04d3a08..f808ca9 100644 --- a/react/public/var/config.json +++ b/react/public/var/config.json @@ -1,5 +1,5 @@ { - "socket_server_port": 80, + "socket_server_port": 8080, "socket_server_address": "*", "debug": true, "max_upload_size": 536870912, diff --git a/react/src/AboutDialog.tsx b/react/src/AboutDialog.tsx index ab7716f..848dfce 100644 --- a/react/src/AboutDialog.tsx +++ b/react/src/AboutDialog.tsx @@ -184,7 +184,7 @@ const AboutDialog = withStyles(styles, { withTheme: true })( render() { let classes = this.props.classes; - + let addressKey = 0; return ( { this.props.onClose() }} TransitionComponent={Transition} @@ -228,7 +228,7 @@ const AboutDialog = withStyles(styles, { withTheme: true })( {this.model.isAndroidHosted() && ( - Copyright © 2022 Robin Davies. + Copyright © 2022-2024 Robin Davies. )} @@ -239,7 +239,7 @@ const AboutDialog = withStyles(styles, { withTheme: true })( { this.model.serverVersion?.webAddresses.map((address) => ( - + {address} )) diff --git a/react/src/AndroidHost.tsx b/react/src/AndroidHost.tsx index 6c853b9..bd93f1a 100644 --- a/react/src/AndroidHost.tsx +++ b/react/src/AndroidHost.tsx @@ -28,7 +28,7 @@ export interface AndroidHostInterface { getHostVersion() : string; chooseNewDevice() : void; setDisconnected(isDisconnected: boolean): void; - + launchExternalUrl(url:string): boolean; }; export class FakeAndroidHost implements AndroidHostInterface @@ -47,4 +47,8 @@ export class FakeAndroidHost implements AndroidHostInterface } showSponsorship() : void { } -} \ No newline at end of file + launchExternalUrl(url:string): boolean + { + return false; + } +} diff --git a/react/src/AppThemed.tsx b/react/src/AppThemed.tsx index 56829fc..989ffaf 100644 --- a/react/src/AppThemed.tsx +++ b/react/src/AppThemed.tsx @@ -48,7 +48,7 @@ import SettingsDialog from './SettingsDialog'; import AboutDialog from './AboutDialog'; import BankDialog from './BankDialog'; -import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo } from './PiPedalModel'; +import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo,wantsLoadingScreen } from './PiPedalModel'; import ZoomedUiControl from './ZoomedUiControl' import MainPage from './MainPage'; import DialogContent from '@mui/material/DialogContent'; @@ -60,6 +60,7 @@ import RenameDialog from './RenameDialog'; import JackStatusView from './JackStatusView'; import { Theme } from '@mui/material/styles'; import { isDarkMode } from './DarkMode'; +import UpdateDialog from './UpdateDialog'; import { ReactComponent as RenameOutlineIcon } from './svg/drive_file_rename_outline_black_24dp.svg'; import { ReactComponent as SaveBankAsIcon } from './svg/ic_save_bank_as.svg'; @@ -270,6 +271,7 @@ type AppState = { tinyToolBar: boolean; alertDialogOpen: boolean; alertDialogMessage: string; + updateDialogOpen: boolean; isSettingsDialogOpen: boolean; onboarding: boolean; isDebug: boolean; @@ -324,6 +326,7 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< alertDialogMessage: "", presetName: this.model_.presets.get().getSelectedText(), isSettingsDialogOpen: false, + updateDialogOpen: false, onboarding: false, isDebug: true, presetChanged: this.model_.presets.get().presetChanged, @@ -339,8 +342,10 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< }; + this.promptForUpdateHandler = this.promptForUpdateHandler.bind(this); this.errorChangeHandler_ = this.setErrorMessage.bind(this); - this.unmountListener = this.unmountListener.bind(this); + this.beforeUnloadListener = this.beforeUnloadListener.bind(this); + this.unloadListener = this.unloadListener.bind(this); this.stateChangeHandler_ = this.setDisplayState.bind(this); this.presetChangedHandler = this.presetChangedHandler.bind(this); this.alertMessageChangedHandler = this.alertMessageChangedHandler.bind(this); @@ -510,18 +515,26 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< this.setState({ isFullScreen: !this.state.isFullScreen }); } - private unmountListener(e: Event) { - if (this.model_.state.get() === State.Ready && !this.model_.isAndroidHosted()) { - e.preventDefault(); - (e as any).returnValue = "Are you sure you want to leave this page?"; - return "Are you sure you want to leave this page?"; - } + private unloadListener(e: Event) { + this.model_.close(); return undefined; } + + private beforeUnloadListener(e: Event) { + this.model_.close(); + return undefined; + } + promptForUpdateHandler(newValue: boolean) { + if (this.state.updateDialogOpen !== newValue) + { + this.setState({updateDialogOpen: newValue}); + } + } componentDidMount() { super.componentDidMount(); - window.addEventListener("beforeunload", this.unmountListener); + window.addEventListener("beforeunload", this.beforeUnloadListener); + window.addEventListener("unload", this.unloadListener); this.model_.errorMessage.addOnChangedHandler(this.errorChangeHandler_); this.model_.state.addOnChangedHandler(this.stateChangeHandler_); @@ -529,6 +542,7 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< this.model_.alertMessage.addOnChangedHandler(this.alertMessageChangedHandler); this.model_.banks.addOnChangedHandler(this.banksChangedHandler); this.model_.showStatusMonitor.addOnChangedHandler(this.showStatusMonitorHandler); + this.model_.promptForUpdate.addOnChangedHandler(this.promptForUpdateHandler); this.alertMessageChangedHandler(); } @@ -550,14 +564,17 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< componentWillUnmount() { super.componentWillUnmount(); - window.removeEventListener("beforeunload", this.unmountListener); + window.removeEventListener("beforeunload", this.beforeUnloadListener); + this.model_.promptForUpdate.removeOnChangedHandler(this.promptForUpdateHandler); this.model_.errorMessage.removeOnChangedHandler(this.errorChangeHandler_); this.model_.state.removeOnChangedHandler(this.stateChangeHandler_); this.model_.pedalboard.removeOnChangedHandler(this.presetChangedHandler); this.model_.banks.removeOnChangedHandler(this.banksChangedHandler); this.model_.banks.removeOnChangedHandler(this.showStatusMonitorHandler); + this.model_.close(); + } alertMessageChangedHandler() { @@ -661,6 +678,10 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< return "Applying\u00A0changes..."; case State.ReloadingPlugins: return "Reloading\u00A0plugins..."; + case State.DownloadingUpdate: + return "Downloading update..."; + case State.InstallingUpdate: + return "Installing update...."; default: return "Reconnecting..."; } @@ -840,7 +861,10 @@ const AppThemed = withStyles(appStyles)(class extends ResizeResponsiveComponent< this.setState({ bankDialogOpen: false })} /> - this.setState({ aboutDialogOpen: false })} /> + { (this.state.aboutDialogOpen)&& + ( + this.setState({ aboutDialogOpen: false })} /> + )} { this.model_.zoomedUiControl.set(undefined); } } /> + { + (this.state.updateDialogOpen) + && ( + + ) + } + {this.state.showStatusMonitor && ()} + + + - {this.state.showStatusMonitor && ()} +
diff --git a/react/src/PiPedalModel.tsx b/react/src/PiPedalModel.tsx index 89e73b3..fd3e455 100644 --- a/react/src/PiPedalModel.tsx +++ b/react/src/PiPedalModel.tsx @@ -20,7 +20,7 @@ import { UiPlugin, UiControl, PluginType, UiFileProperty } from './Lv2Plugin'; import { PiPedalArgumentError, PiPedalStateError } from './PiPedalError'; - +import { UpdateStatus, UpdatePolicyT} from './Updater'; import { ObservableProperty } from './ObservableProperty'; import { Pedalboard, PedalboardItem, ControlValue } from './Pedalboard' import PluginClass from './PluginClass'; @@ -38,7 +38,7 @@ import GovernorSettings from './GovernorSettings'; import WifiChannel from './WifiChannel'; import AlsaDeviceInfo from './AlsaDeviceInfo'; import { AndroidHostInterface, FakeAndroidHost } from './AndroidHost'; -import {ColorTheme, getColorScheme,setColorScheme} from './DarkMode'; +import { ColorTheme, getColorScheme, setColorScheme } from './DarkMode'; import FilePropertyDirectoryTree from './FilePropertyDirectoryTree'; @@ -49,13 +49,21 @@ export enum State { Background, Reconnecting, ApplyingChanges, - ReloadingPlugins + ReloadingPlugins, + DownloadingUpdate, + InstallingUpdate, }; +export function wantsLoadingScreen(state: State) +{ + return state >= State.Reconnecting; +} + export enum ReconnectReason { Disconnected, LoadingSettings, ReloadingPlugins, + Updating, }; export type ControlValueChangedHandler = (key: string, value: number) => void; @@ -369,6 +377,9 @@ export class PiPedalModel //implements PiPedalModel state: ObservableProperty = new ObservableProperty(State.Loading); visibilityState: ObservableProperty = new ObservableProperty(VisibilityState.Visible); + promptForUpdate: ObservableProperty = new ObservableProperty(false); + updateStatus: ObservableProperty = new ObservableProperty(new UpdateStatus()); + errorMessage: ObservableProperty = new ObservableProperty(""); alertMessage: ObservableProperty = new ObservableProperty(""); @@ -431,23 +442,32 @@ export class PiPedalModel //implements PiPedalModel this.onSocketConnectionLost = this.onSocketConnectionLost.bind(this); } - onSocketReconnecting(retry: number, maxRetries: number): void { - if (this.visibilityState.get() === VisibilityState.Hidden) return; + expectDisconnect(reason: ReconnectReason) + { + this.reconnectReason = reason; + } + + onSocketReconnecting(retry: number, maxRetries: number): boolean { + if (this.isClosed) return false; + if (this.visibilityState.get() === VisibilityState.Hidden) return false; //if (retry !== 0) { - switch (this.reconnectReason) - { - case ReconnectReason.Disconnected: - default: - this.setState(State.Reconnecting); - break; - case ReconnectReason.LoadingSettings: - this.setState(State.ApplyingChanges); - break; - case ReconnectReason.ReloadingPlugins: - this.setState(State.ReloadingPlugins); - break; + switch (this.reconnectReason) { + case ReconnectReason.Disconnected: + default: + this.setState(State.Reconnecting); + break; + case ReconnectReason.LoadingSettings: + this.setState(State.ApplyingChanges); + break; + case ReconnectReason.ReloadingPlugins: + this.setState(State.ReloadingPlugins); + break; + case ReconnectReason.Updating: + this.setState(State.InstallingUpdate); + break; } + return true; } @@ -486,17 +506,17 @@ export class PiPedalModel //implements PiPedalModel } else if (message === "onOutputVolumeChanged") { let value = body as number; - this._setOutputVolume(value,false); + this._setOutputVolume(value, false); } else if (message === "onInputVolumeChanged") { let value = body as number; - this._setInputVolume(value,false); + this._setInputVolume(value, false); } else if (message === "onLv2StateChanged") { let instanceId = body.instanceId as number; - let state = body.state as [boolean,any]; + let state = body.state as [boolean, any]; - this.onLv2StateChanged(instanceId,state); + this.onLv2StateChanged(instanceId, state); } else if (message === "onVst3ControlChanged") { let controlChangedBody = body as Vst3ControlChangedBody; this._setVst3PedalboardControlValue( @@ -613,18 +633,114 @@ export class PiPedalModel //implements PiPedalModel } else if (message === "onSystemMidiBindingsChanged") { let bindings = MidiBinding.deserialize_array(body); this.systemMidiBindings.set(bindings); - } else if (message === "onErrorMessage") - { + } else if (message === "onErrorMessage") { this.showAlert(body as string); - } - else if (message === "onLv2PluginsChanging") - { + } + else if (message === "onLv2PluginsChanging") { this.onLv2PluginsChanging(); + } else if (message === "onUpdateStatusChanged") { + let updateStatus = new UpdateStatus().deserialize(body); + this.onUpdateStatusChanged(updateStatus); } } - onLv2PluginsChanging() : void { - this.reconnectReason = ReconnectReason.ReloadingPlugins; + + private updateLaterTimeout?: NodeJS.Timeout = undefined; + + private clearPromptForUpdateTimer() + { + if (this.updateLaterTimeout) + { + clearTimeout(this.updateLaterTimeout); + this.updateLaterTimeout = undefined; + } + } + + private setPromptForUpdateTimer(when: Date) + { + let ms = when.getTime()-Date.now(); + this.updateLaterTimeout = setTimeout( + ()=>{ + this.updateLaterTimeout = undefined; + // make the server do a fresh check + this.getUpdateStatus() + .then( + ()=>{ + this.updatePromptForUpdate(); + }) + .catch((e)=>{ + + }); + }, + ms); + } + + + private updatePromptForUpdate() { + this.clearPromptForUpdateTimer(); + + let stateEnabled = true; // must be present to accept alerts. this.state.get() === State.Ready; + let timeEnabled = false; + + let updateLaterTime = this.getUpdateTime(); + if (updateLaterTime == null) { + timeEnabled = true; + } else { + let nDate = Date.now(); + + let now: Date = new Date(nDate); + let tomorrow: Date = new Date(nDate + 86400000); + + timeEnabled = (updateLaterTime < now || updateLaterTime >= tomorrow) + if (!timeEnabled) + { + this.setPromptForUpdateTimer(updateLaterTime); + } + } + let updateStatus = this.updateStatus.get(); + let statusEnabled = updateStatus.isOnline && updateStatus.isValid && updateStatus.getActiveRelease().updateAvailable; + + if (stateEnabled && timeEnabled && statusEnabled) + { + this.showUpdateDialogValue = true; + } + this.promptForUpdate.set(this.showUpdateDialogValue); + + if (stateEnabled && statusEnabled && !timeEnabled && updateLaterTime) + { + this.setPromptForUpdateTimer(updateLaterTime); + } + } + private showUpdateDialogValue: boolean = false; + + showUpdateDialog(show: boolean = true) { + if (this.showUpdateDialogValue !== show) + { + this.showUpdateDialogValue = show; + if (show) + { + this.forceUpdateCheck(); + } + this.updatePromptForUpdate(); + } + } + + onUpdateStatusChanged(updateStatus: UpdateStatus): void { + let current = this.updateStatus.get(); + if (!current.equals(updateStatus)) { + this.updateStatus.set(updateStatus); + if (current.currentVersion.length !== 0 && current.currentVersion !== updateStatus.currentVersion) { + // !! Server has been updated!!! + this.reloadPage(); + } + if (updateStatus.getActiveRelease().updateAvailable) { + this.promptForUpdate.set(true); + } + this.updatePromptForUpdate() + } + } + onLv2PluginsChanging(): void { + this.expectDisconnect(ReconnectReason.ReloadingPlugins); // this.webSocket?.reconnect(); // let the server do it for us. } @@ -639,19 +755,21 @@ export class PiPedalModel //implements PiPedalModel setState(state: State) { if (this.state.get() !== state) { this.state.set(state); + this.updatePromptForUpdate(); } } - validatePlugins(plugin: PluginClass) { + validatePluginClasses(plugin: PluginClass) { if (plugin.plugin_type === PluginType.None) { console.log("Error: No plugin type for uri '" + plugin.uri + "'"); } for (let i = 0; i < plugin.children.length; ++i) { - this.validatePlugins(plugin.children[i]); + this.validatePluginClasses(plugin.children[i]); } } + private reconnectReason: ReconnectReason = ReconnectReason.Disconnected; isReloading(): boolean { @@ -681,13 +799,17 @@ export class PiPedalModel //implements PiPedalModel this.androidHost?.setDisconnected(false); } - this.reconnectReason = ReconnectReason.Disconnected; + this.expectDisconnect(ReconnectReason.Disconnected); // the next expected disconnect will be an actual disconnect. if (this.visibilityState.get() === VisibilityState.Hidden) return; // reload state, but not configuration. this.getWebSocket().request("hello") .then(clientId => { this.clientId = clientId; + return this.getUpdateStatus(); // detects whether server has been upgraded. + }) + .then((updateStatus) => { + return this.getWebSocket().request("plugins"); }) .then(data => { @@ -774,7 +896,7 @@ export class PiPedalModel //implements PiPedalModel } - maxFileUploadSize: number = 512*1024*1024; + maxFileUploadSize: number = 512 * 1024 * 1024; maxPresetUploadSize: number = 1024 * 1024; debug: boolean = false; @@ -795,7 +917,7 @@ export class PiPedalModel //implements PiPedalModel this.androidHost = new FakeAndroidHost(); } this.debug = !!data.debug; - let { socket_server_port, socket_server_address,max_upload_size } = data; + let { socket_server_port, socket_server_address, max_upload_size } = data; if ((!socket_server_address) || socket_server_address === "*") { socket_server_address = window.location.hostname; } @@ -825,6 +947,9 @@ export class PiPedalModel //implements PiPedalModel return false; }) .then(() => { + return this.getUpdateStatus(); + }) + .then((updateStatus) => { const isoRequest = new Request('iso_codes.json'); return fetch(isoRequest); }) @@ -868,7 +993,7 @@ export class PiPedalModel //implements PiPedalModel .then((data) => { this.plugin_classes.set(new PluginClass().deserialize(data)); - this.validatePlugins(this.plugin_classes.get()); + this.validatePluginClasses(this.plugin_classes.get()); return this.getWebSocket().request("getPresets"); }) @@ -1131,7 +1256,7 @@ export class PiPedalModel //implements PiPedalModel } } - private onLv2StateChanged(instanceId: number, state: [boolean,any]): void { + private onLv2StateChanged(instanceId: number, state: [boolean, any]): void { let item = this.pedalboard.get().getItem(instanceId); item.lv2State = state; for (let item of this.stateChangedListeners) { @@ -1182,33 +1307,31 @@ export class PiPedalModel //implements PiPedalModel this.webSocket?.send("setPatchProperty", body); } - setInputVolume(volume_db: number) : void { - this._setInputVolume(volume_db,true); + setInputVolume(volume_db: number): void { + this._setInputVolume(volume_db, true); } - previewInputVolume(volume_db: number) : void { - nullCast(this.webSocket).send("previewInputVolume",volume_db); + previewInputVolume(volume_db: number): void { + nullCast(this.webSocket).send("previewInputVolume", volume_db); } - previewOutputVolume(volume_db: number) : void { - nullCast(this.webSocket).send("previewOutputVolume",volume_db); + previewOutputVolume(volume_db: number): void { + nullCast(this.webSocket).send("previewOutputVolume", volume_db); } - private _setInputVolume(volume_db: number, notifyServer: boolean) : void { - let changed: boolean = false; + private _setInputVolume(volume_db: number, notifyServer: boolean): void { + let changed: boolean = false; let pedalboard = this.pedalboard.get(); - if (pedalboard.input_volume_db !== volume_db) - { + if (pedalboard.input_volume_db !== volume_db) { let newPedalboard = pedalboard.clone(); newPedalboard.input_volume_db = volume_db; this.pedalboard.set(newPedalboard); changed = true; } - if (changed) - { + if (changed) { if (notifyServer) { - nullCast(this.webSocket).send("setInputVolume",volume_db); + nullCast(this.webSocket).send("setInputVolume", volume_db); } for (let i = 0; i < this._controlValueChangeItems.length; ++i) { @@ -1220,26 +1343,23 @@ export class PiPedalModel //implements PiPedalModel } } - setOutputVolume(volume_db: number, notifyServer: boolean) : void - { - this._setOutputVolume(volume_db,true); + setOutputVolume(volume_db: number, notifyServer: boolean): void { + this._setOutputVolume(volume_db, true); } - private _setOutputVolume(volume_db: number, notifyServer: boolean) : void { - let changed: boolean = false; + private _setOutputVolume(volume_db: number, notifyServer: boolean): void { + let changed: boolean = false; let pedalboard = this.pedalboard.get(); - if (pedalboard.output_volume_db !== volume_db) - { + if (pedalboard.output_volume_db !== volume_db) { let newPedalboard = pedalboard.clone(); newPedalboard.output_volume_db = volume_db; this.pedalboard.set(newPedalboard); changed = true; } - if (changed) - { + if (changed) { if (notifyServer) { - nullCast(this.webSocket).send("setOutputVolume",volume_db); + nullCast(this.webSocket).send("setOutputVolume", volume_db); } for (let i = 0; i < this._controlValueChangeItems.length; ++i) { let item = this._controlValueChangeItems[i]; @@ -1256,15 +1376,13 @@ export class PiPedalModel //implements PiPedalModel if (pedalboard === undefined) throw new PiPedalStateError("Pedalboard not ready."); let newPedalboard = pedalboard.clone(); - if (instanceId === Pedalboard.START_CONTROL && key === "volume_db") - { - this._setInputVolume(value,notifyServer); + if (instanceId === Pedalboard.START_CONTROL && key === "volume_db") { + this._setInputVolume(value, notifyServer); return; - } else if (instanceId === Pedalboard.END_CONTROL) - { - this._setOutputVolume(value,notifyServer); + } else if (instanceId === Pedalboard.END_CONTROL) { + this._setOutputVolume(value, notifyServer); return; - } + } let item = newPedalboard.getItem(instanceId); let changed = item.setControlValue(key, value); @@ -1365,7 +1483,7 @@ export class PiPedalModel //implements PiPedalModel while (true) { let v = it.next(); if (v.done) break; - let item = v.value; + let item = v.value; if (item.instanceId === itemId) { item.deserialize(new PedalboardItem()); // skeezy way to re-initialize. item.instanceId = ++newPedalboard.nextInstanceId; @@ -1395,16 +1513,14 @@ export class PiPedalModel //implements PiPedalModel // mouse is down. Don't update EVERYBODY, but we must change // the control on the running audio plugin. // TODO: respect "expensive" port attribute. - if (instanceId === Pedalboard.START_CONTROL && key === "volume_db") - { + if (instanceId === Pedalboard.START_CONTROL && key === "volume_db") { this.previewInputVolume(value); return; - } else if (instanceId === Pedalboard.END_CONTROL) - { + } else if (instanceId === Pedalboard.END_CONTROL) { this.previewOutputVolume(value); return; - } - + } + this._setServerControl("previewControl", instanceId, key, value); } @@ -1530,13 +1646,11 @@ export class PiPedalModel //implements PiPedalModel } addPedalboardItem(instanceId: number, append: boolean): number { let pedalboard = this.pedalboard.get(); - if (instanceId === Pedalboard.START_CONTROL && append) - { + if (instanceId === Pedalboard.START_CONTROL && append) { instanceId = pedalboard.items[0].instanceId; append = false; - } else if (instanceId === Pedalboard.END_CONTROL && !append) - { - instanceId = pedalboard.items[pedalboard.items.length-1].instanceId; + } else if (instanceId === Pedalboard.END_CONTROL && !append) { + instanceId = pedalboard.items[pedalboard.items.length - 1].instanceId; append = true; } let newPedalboard = pedalboard.clone(); @@ -1555,14 +1669,12 @@ export class PiPedalModel //implements PiPedalModel } addPedalboardSplitItem(instanceId: number, append: boolean): number { let pedalboard = this.pedalboard.get(); - - if (instanceId === Pedalboard.START_CONTROL && append) - { + + if (instanceId === Pedalboard.START_CONTROL && append) { instanceId = pedalboard.items[0].instanceId; append = false; - } else if (instanceId === Pedalboard.END_CONTROL && !append) - { - instanceId = pedalboard.items[pedalboard.items.length-1].instanceId; + } else if (instanceId === Pedalboard.END_CONTROL && !append) { + instanceId = pedalboard.items[pedalboard.items.length - 1].instanceId; append = true; } @@ -1615,7 +1727,6 @@ export class PiPedalModel //implements PiPedalModel } - saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise { // default behaviour is to save after the currently selected preset. if (saveAfterInstanceId === -1) { @@ -1636,21 +1747,94 @@ export class PiPedalModel //implements PiPedalModel }); } + getUpdateStatus(): Promise { + return new Promise( + (accept, reject) => { + nullCast(this.webSocket) + .request('getUpdateStatus') + .then((result) => { + let updateStatus = new UpdateStatus().deserialize(result); + this.onUpdateStatusChanged(updateStatus); + accept(result); + }).catch( + (e) => { + reject(e); + } + + ); + + }); + + } + launchExternalUrl(url: string) { + if (this.isAndroidHosted()) { + try { + this.androidHost?.launchExternalUrl(url); + } catch (e) { + // if they haven't updated their client yet, just don't do it. + } + + + } else { + window.open(url, "_blank")?.focus(); + } + } + + updateLater(delayMs: number) { + let futureDate = new Date(Date.now() + delayMs); + localStorage.setItem('nextUpdateTime', futureDate.toISOString()); + + this.updatePromptForUpdate(); + } + + updateNow(): Promise { + return new Promise( + (accept,reject) => { + let updateStatus = this.updateStatus.get(); + if (updateStatus.isOnline && updateStatus.isValid && updateStatus.getActiveRelease().updateAvailable) + { + this.setState(State.DownloadingUpdate); + let url = updateStatus.getActiveRelease().updateUrl; + nullCast(this.webSocket) + .request('updateNow', url) + .then(()=> { + this.setState(State.InstallingUpdate); + accept(); + }) + .catch( + (e: any)=>{ + this.setState(State.Ready); // TODO: hopefully we haven't had an intermediate disconnect. + reject(e); + }); + } else { + reject(new Error("Invalid update request.")); + } + + } + ); + } + + getUpdateTime(): Date | null { + let item = localStorage.getItem('nextUpdateTime'); + if (item) { + return new Date(item); + } + return null; + } // deprecated. requestFileList(piPedalFileProperty: UiFileProperty): Promise { return nullCast(this.webSocket) .request('requestFileList', piPedalFileProperty); } - requestFileList2(relativeDirectoryPath: string,piPedalFileProperty: UiFileProperty): Promise { + requestFileList2(relativeDirectoryPath: string, piPedalFileProperty: UiFileProperty): Promise { return nullCast(this.webSocket) .request('requestFileList2', - {relativePath: relativeDirectoryPath, fileProperty: piPedalFileProperty} + { relativePath: relativeDirectoryPath, fileProperty: piPedalFileProperty } ); } - - deleteUserFile(fileName: string) : Promise - { - return nullCast(this.webSocket).request('deleteUserFile',fileName); + + deleteUserFile(fileName: string): Promise { + return nullCast(this.webSocket).request('deleteUserFile', fileName); } @@ -1718,7 +1902,7 @@ export class PiPedalModel //implements PiPedalModel setJackSettings(jackSettings: JackChannelSelection): void { - this.reconnectReason = ReconnectReason.LoadingSettings; + this.expectDisconnect(ReconnectReason.LoadingSettings); this.webSocket?.send("setJackSettings", jackSettings); } @@ -1823,7 +2007,15 @@ export class PiPedalModel //implements PiPedalModel } } - + private isClosed = false; + close() { + if (!this.isClosed) + { + this.isClosed = true; + this.webSocket?.close(); + this.webSocket = undefined; + } + } setPatchProperty(instanceId: number, uri: string, value: any): Promise { let result = new Promise((resolve, reject) => { if (this.webSocket) { @@ -2107,7 +2299,7 @@ export class PiPedalModel //implements PiPedalModel window.open(url, "_blank"); } - uploadFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise { + uploadFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise { let result = new Promise((resolve, reject) => { try { if (file.size > this.maxFileUploadSize) { @@ -2117,17 +2309,15 @@ export class PiPedalModel //implements PiPedalModel let parsedUrl = new URL(url); let fileNameOnly = file.name.split('/').pop()?.split('\\')?.pop(); let query = parsedUrl.search; - if (query.length === 0) - { + if (query.length === 0) { query += '?'; } else { query += '&'; } - if (!fileNameOnly) - { + if (!fileNameOnly) { reject("Invalid filename."); } - query += "filename=" + encodeURIComponent(fileNameOnly??""); + query += "filename=" + encodeURIComponent(fileNameOnly ?? ""); parsedUrl.search = query; url = parsedUrl.toString(); fetch( @@ -2146,7 +2336,7 @@ export class PiPedalModel //implements PiPedalModel reject("Upload failed. " + response.statusText); return; } else { - return response.json(); + return response.json(); } }) .then((json) => { @@ -2254,8 +2444,7 @@ export class PiPedalModel //implements PiPedalModel return new Promise((resolve, reject) => { let ws = this.webSocket; - if (!ws) - { + if (!ws) { resolve(); return; } @@ -2271,8 +2460,7 @@ export class PiPedalModel //implements PiPedalModel }); }); } - createNewSampleDirectory(relativePath: string, uiFileProperty: UiFileProperty) : Promise - { + createNewSampleDirectory(relativePath: string, uiFileProperty: UiFileProperty): Promise { return new Promise((resolve, reject) => { let ws = this.webSocket; @@ -2295,8 +2483,8 @@ export class PiPedalModel //implements PiPedalModel }); }); } - - getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty) : Promise { + + getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty): Promise { return new Promise((resolve, reject) => { let ws = this.webSocket; if (!ws) { @@ -2306,15 +2494,14 @@ export class PiPedalModel //implements PiPedalModel ws.request( "getFilePropertyDirectoryTree", uiFileProperty - ).then((result)=> { + ).then((result) => { resolve(new FilePropertyDirectoryTree().deserialize(result)); }).catch((e) => { reject(e); }); }); } - renameFilePropertyFile(oldRelativePath: string, newRelativePath: string, uiFileProperty: UiFileProperty) : Promise - { + renameFilePropertyFile(oldRelativePath: string, newRelativePath: string, uiFileProperty: UiFileProperty): Promise { return new Promise((resolve, reject) => { let ws = this.webSocket; @@ -2441,11 +2628,11 @@ export class PiPedalModel //implements PiPedalModel .catch((err) => { //resolve(); }); - resolve(); + resolve(); }); - this.reconnectReason = ReconnectReason.LoadingSettings; - this.webSocket?.reconnect(); // close immediately, and wait for recoonnect. + this.expectDisconnect(ReconnectReason.LoadingSettings); + // this.webSocket?.reconnect(); // avoid races by letting the server do it for us. return result; } @@ -2524,8 +2711,7 @@ export class PiPedalModel //implements PiPedalModel ++ix; if (ix >= uiPlugin.controls.length) return; - while (!uiPlugin.controls[ix].is_input) - { + while (!uiPlugin.controls[ix].is_input) { ++ix; if (ix >= uiPlugin.controls.length) return; } @@ -2558,8 +2744,7 @@ export class PiPedalModel //implements PiPedalModel --ix; if (ix < 0) return; - while (!uiPlugin.controls[ix].is_input) - { + while (!uiPlugin.controls[ix].is_input) { --ix; if (ix < 0) return; } @@ -2590,6 +2775,20 @@ export class PiPedalModel //implements PiPedalModel } + setUpdatePolicy(updatePolicy: UpdatePolicyT) : void { + let iPolicy = updatePolicy as number; + if (this.webSocket) { + this.webSocket.send("setUpdatePolicy", iPolicy); + } + + } + forceUpdateCheck() { + if (this.webSocket) { + this.webSocket.send("forceUpdateCheck"); + } + + } + preloadImages(imageList: string): void { let imageNames = imageList.split(';'); for (let i = 0; i < imageNames.length; ++i) { @@ -2619,8 +2818,7 @@ export class PiPedalModel //implements PiPedalModel } // returns the ID of the new preset. - newPresetItem(createAfter: number): Promise - { + newPresetItem(createAfter: number): Promise { return nullCast(this.webSocket).request("newPreset"); } @@ -2630,14 +2828,13 @@ export class PiPedalModel //implements PiPedalModel setTheme(value: ColorTheme) { - if (this.getTheme() !== value) - { + if (this.getTheme() !== value) { setColorScheme(value); - setTimeout(()=>{ + setTimeout(() => { this.reloadPage(); }, - 200); -} + 200); + } } @@ -2647,7 +2844,6 @@ export class PiPedalModel //implements PiPedalModel window.location.href = url; //window.location.reload(); } - }; let instance: PiPedalModel | undefined = undefined; diff --git a/react/src/PiPedalSocket.tsx b/react/src/PiPedalSocket.tsx index 6564cba..8a10bea 100644 --- a/react/src/PiPedalSocket.tsx +++ b/react/src/PiPedalSocket.tsx @@ -41,7 +41,7 @@ export interface PiPedalSocketListener { onError: (message: string, exception?: Error) => void; onConnectionLost: () => void; onReconnect: () => void; - onReconnecting: (retry: number, maxRetries: number) => void; + onReconnecting: (retry: number, maxRetries: number) => boolean; }; class PiPedalSocket { @@ -183,6 +183,7 @@ class PiPedalSocket { handleClose(_event: any): any { if (this.retrying) { + this.close(); // treat this as a fatal error. if (this.listener) { this.listener.onError("Server connection lost."); @@ -230,7 +231,9 @@ class PiPedalSocket { this.close(); } - this.listener.onReconnecting(this.retryCount,MAX_RETRIES); + if (!this.listener.onReconnecting(this.retryCount,MAX_RETRIES)) { + return; + } ++this.retryCount; this.connectInternal_() @@ -262,6 +265,7 @@ class PiPedalSocket { this.socket.onmessage = null; this.socket.onopen = null; this.socket.close(); + this.socket = undefined; } } catch (ignored) { diff --git a/react/src/SettingsDialog.tsx b/react/src/SettingsDialog.tsx index bd1d863..7b0726b 100644 --- a/react/src/SettingsDialog.tsx +++ b/react/src/SettingsDialog.tsx @@ -393,6 +393,12 @@ const SettingsDialog = withStyles(styles, { withTheme: true })( showThemeSelectDialog: true }); } + + handleCheckForUpdates() + { + this.model.showUpdateDialog(); + } + handleMidiMessageSettings() { this.setState({ showSystemMidiBindingsDialog: true @@ -786,6 +792,18 @@ const SettingsDialog = withStyles(styles, { withTheme: true })(
+ { this.handleCheckForUpdates(); }} > + +
+ + Check for updates... +
+
+ + + this.handleRestart()} > diff --git a/react/src/UpdateDialog.tsx b/react/src/UpdateDialog.tsx new file mode 100644 index 0000000..aee7a05 --- /dev/null +++ b/react/src/UpdateDialog.tsx @@ -0,0 +1,284 @@ +/* + * MIT License + * + * Copyright (c) 2022 Robin E. R. Davies + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import DialogEx from './DialogEx'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import { UpdateStatus, UpdateRelease, UpdatePolicyT,intToUpdatePolicyT } from './Updater'; +import { PiPedalModelFactory, PiPedalModel } from './PiPedalModel'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; + + +//const UPDATE_CHECK_DELAY = 86400000; // one day in ms. +const UPDATE_CHECK_DELAY = 30 * 1000; // testing +const CLOSE_DELAY = 100; // ms. + + + +export interface UpdateDialogProps { + open: boolean; +}; + +export interface UpdateDialogState { + updateStatus: UpdateStatus; + compactLandscape: boolean; + alertDialogOpen: boolean; + alertDialogMessage: string; +}; + +export default class UpdateDialog extends React.Component { + private model: PiPedalModel; + + constructor(props: UpdateDialogProps) { + super(props); + this.model = PiPedalModelFactory.getInstance(); + let updateStatus = this.model.updateStatus.get(); + this.state = { + updateStatus: updateStatus, + compactLandscape: false, + alertDialogOpen: false, + alertDialogMessage: "" + }; + this.onUpdateStatusChanged = this.onUpdateStatusChanged.bind(this); + } + + onUpdateStatusChanged(newValue: UpdateStatus) + { + if (!newValue.equals(this.state.updateStatus)) + { + this.setState({updateStatus: newValue}); + } + } + + private mounted: boolean = false; + + componentDidMount() { + this.mounted = true; + this.model.updateStatus.addOnChangedHandler(this.onUpdateStatusChanged); + } + componentWillUnmount() { + this.model.updateStatus.removeOnChangedHandler(this.onUpdateStatusChanged); + this.mounted = false; + + } + + + private handleOk() { + + } + private handleUpdateNow() { + this.model.updateNow() + .then(() => { }) // all handling is done by model, so we can run ui-less. + .catch((e: any) => { + this.showAlert(e.toString()); + }); + } + private updatingLater: boolean = false; + private handleUpdateLater() { + this.updatingLater = true; + this.model.updateLater(UPDATE_CHECK_DELAY); + this.model.showUpdateDialog(false); // allow close if launched from the settings window. + } + + private handleClose() { + // ideally, we'd like to not close at all, but it screws up the nav backstack if we dont. + // so, close, but do so very briefly + if (!this.updatingLater) { + this.model.updateLater(CLOSE_DELAY); // close and re-open immediately. + } + this.updatingLater = false; + this.model.showUpdateDialog(false); + } + + handleCloseAlert() { + this.setState({ alertDialogOpen: false, alertDialogMessage: "" }) + } + + showAlert(message: string) { + this.setState({ alertDialogOpen: true, alertDialogMessage: message }); + } + + onViewReleaseNotes() { + this.model.launchExternalUrl("https://rerdavies.github.io/pipedal/ReleaseNotes.html"); + } + + upToDate(): boolean { + let updateStatus = this.state.updateStatus; + let updateRelease: UpdateRelease = updateStatus.getActiveRelease(); + return updateStatus.isValid && !updateRelease.updateAvailable + } + canUpgrade(): boolean { + let updateStatus = this.state.updateStatus; + let updateRelease = updateStatus.getActiveRelease(); + + return updateStatus.isValid && updateStatus.isOnline && updateRelease.updateAvailable; + } + + private onPolicySelected(newValue: string | number): void { + if (newValue.toString() === "") return; + let updatePolicy = intToUpdatePolicyT(newValue as number); + this.model.setUpdatePolicy(updatePolicy); + + } + render() { + let updateStatus = this.state.updateStatus; + let updateRelease = updateStatus.getActiveRelease(); + let upToDate = this.upToDate(); + let canUpgrade = this.canUpgrade(); + let compact = this.state.compactLandscape; + return ( + { this.handleClose(); }} + style={{ userSelect: "none" }} + > + { + (!compact) && + ( + +
+ PiPedal Updates + +
+ +
+ ) + } + { + (!compact) && + ( + + ) + } + + + { + (upToDate) && ( + + PiPedal is up to date. + + ) + } + {(canUpgrade) && ( + + A new version of the PiPedal server is available. Would you like to update now? + + + + ) + } + +
+
+ + Current version: + + {(canUpgrade) && ( + + Update version: + + ) + } +
+
+ + {updateStatus.currentVersionDisplayName} + + {(canUpgrade) && ( + + {updateRelease.upgradeVersionDisplayName} + + ) + } +
+
+ { + (updateStatus.errorMessage.length !== 0) && + ( + + { + updateStatus.errorMessage + } + + + ) + } + +
+ + + { + (canUpgrade) ? + ( +
+ +
+ +
+ + + ) : ( + +
{ this.model.showUpdateDialog(false); }} + > +
+ +
+ ) + } + + + { this.handleCloseAlert(); }} + aria-describedby="Alert Dialog" + > + + + { + this.state.alertDialogMessage + } + + + + + + + + + ); + } +} \ No newline at end of file diff --git a/react/src/Updater.tsx b/react/src/Updater.tsx new file mode 100644 index 0000000..ad17194 --- /dev/null +++ b/react/src/Updater.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +export enum UpdatePolicyT { + // must match declaration in Updater.hpp + ReleaseOnly = 0, + ReleaseOrBeta = 1, + Development = 2, + +}; +export function intToUpdatePolicyT(intValue: number): UpdatePolicyT { + return (Object.values(UpdatePolicyT).find(enumValue => enumValue === intValue)) as UpdatePolicyT; +} +export class UpdateRelease { + deserialize(input: any): UpdateRelease { + this.updateAvailable = input.updateAvailable; + this.upgradeVersion = input.upgradeVersion; + this.upgradeVersionDisplayName = input.upgradeVersionDisplayName; + this.assetName = input.assetName; + this.updateUrl = input.updateUrl; + return this; + } + + + updateAvailable: boolean = false; + upgradeVersion: string = ""; //just the version. + upgradeVersionDisplayName: string = ""; // display name for the version. + assetName: string = ""; // filename only + updateUrl: string = ""; // url from which to download the .deb file. + + equals(other: UpdateRelease): boolean { + return (this.updateAvailable === other.updateAvailable) && + (this.upgradeVersion === other.upgradeVersion) && + (this.upgradeVersionDisplayName === other.upgradeVersionDisplayName) && + (this.assetName === other.assetName) && + (this.updateUrl === other.updateUrl) + ; + } +}; + +export class UpdateStatus { + lastUpdateTime: number = 0; + isValid: boolean = false; + errorMessage: string = ""; + updatePolicy: UpdatePolicyT = UpdatePolicyT.ReleaseOrBeta; + isOnline: boolean = false; + currentVersion: string = ""; + currentVersionDisplayName: string = ""; + + releaseOnlyRelease: UpdateRelease = new UpdateRelease(); + releaseOrBetaRelease: UpdateRelease = new UpdateRelease(); + devRelease: UpdateRelease = new UpdateRelease(); + + deserialize(input: any): UpdateStatus { + this.lastUpdateTime = input.lastUpdateTime; + this.isValid = input.isValid; + this.errorMessage = input.errorMessage; + this.isOnline = input.isOnline; + this.updatePolicy = intToUpdatePolicyT(input.updatePolicy); + this.currentVersion = input.currentVersion; + this.currentVersionDisplayName = input.currentVersionDisplayName; + this.releaseOnlyRelease = new UpdateRelease().deserialize(input.releaseOnlyRelease); + this.releaseOrBetaRelease = new UpdateRelease().deserialize(input.releaseOrBetaRelease); + this.devRelease = new UpdateRelease().deserialize(input.devRelease); + return this; + } + + static deserialize_array(input: any): UpdateStatus[] { + let result: UpdateStatus[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new UpdateStatus().deserialize(input[i]); + } + return result; + } + getActiveRelease(): UpdateRelease { + switch (this.updatePolicy) { + case UpdatePolicyT.ReleaseOnly: + return this.releaseOnlyRelease; + case UpdatePolicyT.ReleaseOrBeta: + return this.releaseOrBetaRelease; + case UpdatePolicyT.Development: + return this.devRelease; + default: + throw new Error("Invalid argument."); + } + } + equals(other: UpdateStatus): boolean { + return (this.isValid === other.isValid) + && (this.lastUpdateTime === other.lastUpdateTime) + && (this.errorMessage === other.errorMessage) + && (this.isOnline === other.isOnline) + && (this.currentVersion === other.currentVersion) + && (this.currentVersionDisplayName === other.currentVersionDisplayName) + && (this.releaseOnlyRelease.equals(other.releaseOnlyRelease)) + && (this.releaseOrBetaRelease.equals(other.releaseOrBetaRelease)) + && (this.devRelease.equals(other.devRelease)) + ; + // other properties are contractually dealt with. + + } + + +} \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8b9d091..fd2d44e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -89,12 +89,17 @@ set(CMAKE_CXX_STANDARD_REQUIRED True) set (USE_SANITIZE False) + if (!ENABLE_VST3) - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror -Wno-psabi") + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror -Wno-psabi -DARCHITECTURE=${CPACK_SYSTEM_NAME}") else() set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-multichar -Wno-psabi") endif() +EXECUTE_PROCESS( COMMAND dpkg --print-architecture COMMAND tr -d '\n' OUTPUT_VARIABLE DEBIAN_ARCHITECTURE ) +message(STATUS DEBIAN_ARCHITECTURE="${DEBIAN_ARCHITECTURE}") + +add_compile_definitions(DEBIAN_ARCHITECTURE="${DEBIAN_ARCHITECTURE}") if (CMAKE_BUILD_TYPE MATCHES Debug) message(STATUS "Debug build") @@ -132,6 +137,7 @@ else() endif() set (PIPEDAL_SOURCES + Updater.cpp Updater.hpp Lv2PluginChangeMonitor.cpp Lv2PluginChangeMonitor.hpp WebServerConfig.cpp WebServerConfig.hpp Locale.hpp Locale.cpp @@ -278,7 +284,8 @@ add_executable(pipedaltest testMain.cpp InvertingMutexTest.cpp jsonTest.cpp - + UpdaterTest.cpp + utilTest.cpp AlsaDriverTest.cpp diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 5972714..6495642 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -73,6 +73,11 @@ PiPedalModel::PiPedalModel() #else this->jackServerSettings = this->storage.GetJackServerSettings(); #endif + updater.SetUpdateListener( + [this](const UpdateStatus&updateStatus) + { + this->OnUpdateStatusChanged(updateStatus); + }); } void PiPedalModel::Close() @@ -386,9 +391,9 @@ void PiPedalModel::OnNotifyMaybeLv2StateChanged(uint64_t instanceId) item->stateUpdateCount(item->stateUpdateCount() + 1); - IPiPedalModelSubscriber **t = new IPiPedalModelSubscriber *[this->subscribers.size()]; Lv2PluginState newState = item->lv2State(); + IPiPedalModelSubscriber **t = new IPiPedalModelSubscriber *[this->subscribers.size()]; for (size_t i = 0; i < subscribers.size(); ++i) { t[i] = this->subscribers[i]; @@ -2172,3 +2177,49 @@ void PiPedalModel::SetRestartListener(std::function &&listener) { this->restartListener = std::move(listener); } + +void PiPedalModel::OnUpdateStatusChanged(const UpdateStatus&updateStatus) +{ + std::lock_guard lock(mutex); + + if (this->currentUpdateStatus != updateStatus) + { + this->currentUpdateStatus = updateStatus; + FireUpdateStatusChanged(this->currentUpdateStatus); + } +} +void PiPedalModel::FireUpdateStatusChanged(const UpdateStatus&updateStatus) +{ + std::lock_guard lock(mutex); + + std::vector t; + t.reserve(subscribers.size()); + + for (auto subscriber: subscribers) + { + t.push_back(subscriber); + } + + for (auto subscriber: t) + { + subscriber->OnUpdateStatusChanged(updateStatus); + } +} +UpdateStatus PiPedalModel::GetUpdateStatus() { + std::lock_guard lock(mutex); + return currentUpdateStatus; +} + +void PiPedalModel::UpdateNow(const std::string&updateUrl) +{ + sleep(5); + throw std::runtime_error("Not implemented yet."); + +} +void PiPedalModel::ForceUpdateCheck() { + updater.ForceUpdateCheck(); +} +void PiPedalModel::SetUpdatePolicy(UpdatePolicyT updatePolicy) +{ + updater.SetUpdatePolicy(updatePolicy); +} diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index a9aa49b..1cf8710 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -30,6 +30,7 @@ #include #include #include "Banks.hpp" +#include "Updater.hpp" #include "PiPedalConfiguration.hpp" #include "JackServerSettings.hpp" #include "WifiConfigSettings.hpp" @@ -56,7 +57,7 @@ namespace pipedal virtual void OnControlChanged(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value) = 0; virtual void OnInputVolumeChanged(float value) = 0; virtual void OnOutputVolumeChanged(float value) = 0; - + virtual void OnUpdateStatusChanged(const UpdateStatus&updateStatus) = 0; virtual void OnLv2StateChanged(int64_t pedalItemId,const Lv2PluginState&newState ) = 0; virtual void OnVst3ControlChanged(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value, const std::string &state) = 0; virtual void OnPedalboardChanged(int64_t clientId, const Pedalboard &pedalboard) = 0; @@ -86,6 +87,10 @@ namespace pipedal class PiPedalModel : private IAudioHostCallbacks { private: + + UpdateStatus currentUpdateStatus; + Updater updater; + void OnUpdateStatusChanged(const UpdateStatus&updateStatus); std::function restartListener; std::unique_ptr pluginChangeMonitor; @@ -199,6 +204,9 @@ namespace pipedal PiPedalModel(); virtual ~PiPedalModel(); + UpdateStatus GetUpdateStatus(); + void UpdateNow(const std::string&updateUrl); + void FireUpdateStatusChanged(const UpdateStatus&updateStatus); uint16_t GetWebPort() const { return webPort; } std::filesystem::path GetPluginUploadDirectory() const; void Close(); @@ -334,7 +342,8 @@ namespace pipedal std::map GetFavorites() const; void SetFavorites(const std::map &favorites); - + void SetUpdatePolicy(UpdatePolicyT updatePolicy); + void ForceUpdateCheck(); std::vector GetFileList(const UiFileProperty&fileProperty); std::vector GetFileList2(const std::string &relativePath,const UiFileProperty&fileProperty); diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index ac5b6b7..f165896 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -20,6 +20,7 @@ #include "pch.h" #include "PiPedalSocket.hpp" +#include "Updater.hpp" #include "json.hpp" #include "viewstream.hpp" #include "PiPedalVersion.hpp" @@ -1001,7 +1002,19 @@ public: pReader->read(&handle); this->model.CancelMonitorPatchProperty(this->clientId, handle); } - + else if (message == "getUpdateStatus") + { + UpdateStatus updateStatus = model.GetUpdateStatus(); + this->Reply(replyTo,"getUpdateStatus",updateStatus); + } + else if (message == "updateNow") + { + std::string updateUrl; + pReader->read(&updateUrl); + model.UpdateNow(updateUrl); + bool result = true; + this->Reply(replyTo,"updateNow",result); + } else if (message == "getJackStatus") { JackHostStatus status = model.GetJackStatus(); @@ -1442,6 +1455,18 @@ public: pReader->read(&favorites); this->model.SetFavorites(favorites); } + else if (message == "setUpdatePolicy") + { + int iPolicy; + pReader->read(&iPolicy); + + this->model.SetUpdatePolicy((UpdatePolicyT)iPolicy); + } + else if (message == "forceUpdateCheck") + { + this->model.ForceUpdateCheck(); + + } else if (message == "setSystemMidiBindings") { std::vector bindings; @@ -1581,6 +1606,10 @@ protected: } private: + virtual void OnUpdateStatusChanged(const UpdateStatus&updateStatus) { + Send("onUpdateStatusChanged",updateStatus); + } + virtual void OnLv2StateChanged(int64_t instanceId, const Lv2PluginState &state) { Lv2StateChangedBody message { (uint64_t)instanceId, state}; diff --git a/src/Updater.cpp b/src/Updater.cpp new file mode 100644 index 0000000..b0932f7 --- /dev/null +++ b/src/Updater.cpp @@ -0,0 +1,609 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "Updater.hpp" +#include "json.hpp" +#include "config.hpp" +#include +#include +#include +#include +#include +#include "Lv2Log.hpp" +#include "SysExec.hpp" +#include "json_variant.hpp" +#include "ss.hpp" +#include "Lv2Log.hpp" +#include + +using namespace pipedal; + +#define TEST_UPDATE + +#ifndef DEBUG +#undef TEST_UPDATE // do NOT leat this leak into a production build! +#endif + +static constexpr uint64_t CLOSE_EVENT = 0; +static constexpr uint64_t CHECK_NOW_EVENT = 1; +static constexpr uint64_t UNCACHED_CHECK_NOW_EVENT = 2; + + +static std::filesystem::path WORKING_DIRECTORY = "/var/pipedal/updates"; +static std::filesystem::path UPDATE_STATUS_CACHE_FILE = WORKING_DIRECTORY / "updateStatus.json"; + +static std::string GITHUB_RELEASES_URL = "https://api.github.com/repos/rerdavies/pipedal/releases"; + +Updater::clock::duration Updater::updateRate = std::chrono::duration_cast(std::chrono::days(1)); +static std::chrono::system_clock::duration CACHE_DURATION = std::chrono::duration_cast(std::chrono::minutes(30)); + +std::mutex cacheMutex; +static UpdateStatus GetCachedUpdateStatus() +{ + std::lock_guard lock{cacheMutex}; + try + { + if (std::filesystem::exists(UPDATE_STATUS_CACHE_FILE)) + { + std::ifstream f{UPDATE_STATUS_CACHE_FILE}; + if (!f.is_open()) + { + json_reader reader(f); + UpdateStatus status; + reader.read(&status); + return status; + } + } + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Unable to read cached UpdateStatus. " << e.what())); + } + return UpdateStatus(); +} + +static void SetCachedUpdateStatus(UpdateStatus &updateStatus) +{ + std::lock_guard lock{cacheMutex}; + updateStatus.LastUpdateTime(std::chrono::system_clock::now()); + try + { + std::ofstream f{UPDATE_STATUS_CACHE_FILE}; + json_writer writer{f}; + writer.write(updateStatus); + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Unable to write cached UpdateStatus. " << e.what())); + } +} +Updater::Updater() +{ + cachedUpdateStatus = GetCachedUpdateStatus(); + updatePolicy = cachedUpdateStatus.UpdatePolicy(); + currentResult = cachedUpdateStatus; + + int fds[2]; + int rc = pipe(fds); + if (rc != 0) + { + throw std::runtime_error("Updater: cant create event pipe."); + } + this->event_reader = fds[0]; + this->event_writer = fds[1]; + + this->thread = std::make_unique([this]() + { ThreadProc(); }); + CheckNow(); +} +Updater::~Updater() +{ + Stop(); +} +void Updater::Stop() +{ + if (stopped) + { + return; + } + stopped = true; + + if (event_writer != -1) + { + uint64_t value = CLOSE_EVENT; + write(this->event_writer, &value, sizeof(uint64_t)); + } + if (thread) + { + thread->join(); + thread = nullptr; + } + if (event_reader != -1) + { + close(event_reader); + } + if (event_writer != -1) + { + close(event_writer); + } +} + +void Updater::CheckNow() +{ + uint64_t value = CHECK_NOW_EVENT; + write(this->event_writer, &value, sizeof(uint64_t)); +} + +void Updater::SetUpdateListener(UpdateListener &&listener) +{ + std::lock_guard lock{mutex}; + this->listener = listener; + if (hasInfo) + { + listener(currentResult); + } +} + +void Updater::ThreadProc() +{ + struct pollfd pfd; + pfd.fd = this->event_reader; + pfd.events = POLLIN; + + while (true) + { + int ret = poll(&pfd, 1, std::chrono::duration_cast(updateRate).count()); // 1000 ms timeout + + if (ret == -1) + { + Lv2Log::error("Updater: Poll error."); + break; + } + else if (ret == 0) + { + CheckForUpdate(true); + } + else + { + // Event occurred + uint64_t value; + ssize_t s = read(event_reader, &value, sizeof(uint64_t)); + if (s == sizeof(uint64_t)) + { + if (value == CHECK_NOW_EVENT) + { + CheckForUpdate(true); + } else if (value == UNCACHED_CHECK_NOW_EVENT) + { + CheckForUpdate(false); + } + else + { + break; + } + } + } + } +} + +class GithubAsset +{ +public: + GithubAsset(json_variant &v); + std::string name; + std::string browser_download_url; + std::string updated_at; +}; +class GithubRelease +{ +public: + GithubRelease(json_variant &v); + + const GithubAsset *GetDownloadForCurrentArchitecture() const; + bool draft = true; + bool prerelease = true; + std::string name; + std::string url; + std::string version; + std::string body; + std::vector assets; + std::string published_at; +}; + +GithubAsset::GithubAsset(json_variant &v) +{ + auto o = v.as_object(); + this->name = o->at("name").as_string(); + this->browser_download_url = o->at("browser_download_url").as_string(); + this->updated_at = o->at("updated_at").as_string(); +} +GithubRelease::GithubRelease(json_variant &v) +{ + auto o = v.as_object(); + this->name = o->at("name").as_string(); + this->draft = o->at("draft").as_bool(); + this->prerelease = o->at("prerelease").as_bool(); + this->body = o->at("body").as_string(); + + auto assets = o->at("assets").as_array(); + for (size_t i = 0; i < assets->size(); ++i) + { + auto &el = assets->at(i); + this->assets.push_back(GithubAsset(el)); + } + this->published_at = o->at("published_at").as_string(); +} + +static std::vector split(const std::string &s, char delimiter) +{ + std::vector tokens; + std::string token; + std::istringstream tokenStream(s); + while (std::getline(tokenStream, token, delimiter)) + { + tokens.push_back(token); + } + return tokens; +} +static std::string justTheVersion(const std::string &assetName) +{ + // eg. pipedal_1.2.41_arm64.deb + auto t = split(assetName, '_'); + if (t.size() != 3) + { + throw std::runtime_error("Unable to parse version."); + } + return t[1]; +} + +int compareVersions(const std::string &l, const std::string &r) +{ + std::stringstream sl(l); + std::stringstream sr(r); + + int majorL = -1, majorR = 1, minorL = -1, + minorR = -1, buildL = -1, buildR = -1; + sl >> majorL; + sr >> majorR; + if (majorL != majorR) + { + return (majorL < majorR) ? -1 : 1; + } + char discard; + sl >> discard >> minorL; + sr >> discard >> minorR; + if (minorL != minorR) + { + return minorL < minorR ? -1 : 1; + } + sl >> discard >> buildL; + sr >> discard >> buildR; + + if (buildL != buildR) + { + return buildL < buildR ? -1 : 1; + } + return 0; +} +static std::string normalizeReleaseName(const std::string &releaseName) +{ + // e.g. "PiPedal 1.2.34 Release" -> "PiPedal v1.2.34-Release" + if (releaseName.empty()) + return ""; + + std::string result = releaseName; + + auto nPos = result.find(' '); + if (nPos == std::string::npos) + { + return result; + } + ++nPos; + if (nPos >= result.length()) + { + return result; + } + char c = releaseName[nPos]; + if (c >= '0' && c <= '9') + { + result.insert(result.begin() + nPos, 'v'); + } + nPos = result.find(' ', nPos); + if (nPos != std::string::npos) + { + result.at(nPos) = '-'; + } + return result; +} + +static bool IsCacheValid(const UpdateStatus &updateStatus) +{ + if (!updateStatus.IsValid() || !updateStatus.IsOnline()) + { + return false; + } + auto now = std::chrono::system_clock::now(); + auto validStart = updateStatus.LastUpdateTime(); + auto validEnd = validStart + CACHE_DURATION; + return now >= validStart && now < validEnd; +} + + + +UpdateRelease Updater::getUpdateRelease( + const std::vector &githubReleases, + const std::string ¤tVersion, + const UpdateReleasePredicate &predicate) +{ + for (const auto &githubRelease : githubReleases) + { + auto *asset = githubRelease.GetDownloadForCurrentArchitecture(); + if (!asset) + continue; + + if (!predicate(githubRelease)) + continue; + UpdateRelease updateRelease; + updateRelease.upgradeVersion_ = justTheVersion(asset->name); + updateRelease.updateAvailable_ = compareVersions(currentVersion, updateRelease.upgradeVersion_) < 0; + updateRelease.upgradeVersionDisplayName_ = normalizeReleaseName(githubRelease.name); + updateRelease.assetName_ = asset->name; + updateRelease.updateUrl_ = asset->browser_download_url; + return updateRelease; + } + return UpdateRelease(); +} + +void Updater::CheckForUpdate(bool useCache) +{ + UpdateStatus updateResult; + + { + std::lock_guard lock {mutex}; + if (useCache && IsCacheValid(cachedUpdateStatus)) + { + this->currentResult = cachedUpdateStatus; + this->currentResult.UpdatePolicy(this->updatePolicy); + if (listener) + { + listener(this->currentResult); + } + return; + } + updateResult = this->currentResult; + } + std::string args = SS("-s " << GITHUB_RELEASES_URL); + + + updateResult.errorMessage_ = ""; + + try + { + auto result = sysExecForOutput("curl", args); + if (result.exitCode != EXIT_SUCCESS) + { + throw std::runtime_error("Server has no internet access."); + } + else + { + std::stringstream ss(result.output); + json_reader reader(ss); + json_variant vResult(reader); + + if (vResult.is_object()) + { + // an HTML error. + updateResult.isOnline_ = false; + auto o = vResult.as_object(); + std::string message = o->at("message").as_string(); + auto status_code = o->at("status_code").as_int64(); + throw std::runtime_error(SS("Service error. ()" << status_code << ": " << message << ")")); + } + else + { + json_variant::array_ptr vArray = vResult.as_array(); + + std::vector releases; + for (size_t i = 0; i < vArray->size(); ++i) + { + auto &el = vArray->at(0); + GithubRelease release{el}; + if (!release.draft && release.GetDownloadForCurrentArchitecture() != nullptr) + { + releases.push_back(std::move(release)); + } + } + std::sort( + releases.begin(), + releases.end(), + [](const GithubRelease &left, const GithubRelease &right) + { + return left.published_at > right.published_at; // latest date first. + }); + updateResult.releaseOnlyRelease_ = getUpdateRelease( + releases, + updateResult.currentVersion_, + [](const GithubRelease &githubRelease) + { + return !githubRelease.prerelease && + githubRelease.name.find("Release") != std::string::npos; + }); + + updateResult.releaseOrBetaRelease_ = getUpdateRelease( + releases, + updateResult.currentVersion_, + [](const GithubRelease &githubRelease) + { + return !githubRelease.prerelease && + (githubRelease.name.find("Release") != std::string::npos || + githubRelease.name.find("Beta") != std::string::npos); + }); + updateResult.devRelease_ = getUpdateRelease( + releases, + updateResult.currentVersion_, + [](const GithubRelease &githubRelease) + { + return true; + }); +#ifdef TEST_UPDATE + updateResult.releaseOrBetaRelease_.upgradeVersionDisplayName_ = "PiPedal v1.2.41-Beta"; + updateResult.devRelease_.upgradeVersionDisplayName_ = "PiPedal v1.2.39-Experimental"; + updateResult.devRelease_.upgradeVersion_ = "1.2.39"; + updateResult.devRelease_.updateAvailable_ = false; +#endif + updateResult.isValid_ = true; + updateResult.isOnline_ = true; + } + } + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Failed to fetch update info. " << e.what())); + updateResult.errorMessage_ = e.what(); + updateResult.isValid_ = false; + updateResult.isOnline_ = false; + } + { + std::lock_guard lock{mutex}; + updateResult.UpdatePolicy(this->updatePolicy); + this->currentResult = updateResult; + SetCachedUpdateStatus(this->currentResult); + if (listener) + { + listener(this->currentResult); + } + } +} +bool UpdateRelease::operator==(const UpdateRelease &other) const +{ + return (updateAvailable_ == other.updateAvailable_) && + (upgradeVersion_ == other.upgradeVersion_) && + (upgradeVersionDisplayName_ == other.upgradeVersionDisplayName_) && + (assetName_ == other.assetName_) && + (updateUrl_ == other.updateUrl_); +} + +bool UpdateStatus::operator==(const UpdateStatus &other) const +{ + return (lastUpdateTime_ == other.lastUpdateTime_) && + (isValid_ == other.isValid_) && + (errorMessage_ == other.errorMessage_) && + (isOnline_ == other.isOnline_) && + (currentVersion_ == other.currentVersion_) && + (currentVersionDisplayName_ == other.currentVersionDisplayName_) && + (updatePolicy_ == other.updatePolicy_) && + (releaseOnlyRelease_ == other.releaseOnlyRelease_) && + (releaseOrBetaRelease_ == other.releaseOrBetaRelease_) && + (devRelease_ == other.devRelease_); +} + +UpdatePolicyT Updater::GetUpdatePolicy() +{ + std::lock_guard lock{mutex}; + return updatePolicy; +} +void Updater::SetUpdatePolicy(UpdatePolicyT updatePolicy) +{ + std::lock_guard lock{mutex}; + if (updatePolicy == this->updatePolicy) + return; + this->updatePolicy = updatePolicy; + if (this->currentResult.UpdatePolicy() != updatePolicy) + { + this->currentResult.UpdatePolicy(updatePolicy); + SetCachedUpdateStatus(this->currentResult); + if (listener) + { + listener(currentResult); + } + } +} +void Updater::ForceUpdateCheck() +{ + uint64_t value = UNCACHED_CHECK_NOW_EVENT; + write(this->event_writer, &value, sizeof(uint64_t)); +} + +UpdateStatus::UpdateStatus() +{ + currentVersion_ = PROJECT_VER; + currentVersionDisplayName_ = PROJECT_DISPLAY_VERSION; + +#ifdef TEST_UPDATE + // uncomment this line to test upgrading. + currentVersion_ = "1.2.39"; + currentVersionDisplayName_ = "PiPedal 1.2.39-Debug"; +#endif +} + +std::chrono::system_clock::time_point UpdateStatus::LastUpdateTime() const +{ + std::chrono::system_clock::duration duration{this->lastUpdateTime_}; + std::chrono::system_clock::time_point tp{duration}; + return tp; +} + +void UpdateStatus::LastUpdateTime(const std::chrono::system_clock::time_point &timePoint) +{ + this->lastUpdateTime_ = timePoint.time_since_epoch().count(); +} + +const GithubAsset *GithubRelease::GetDownloadForCurrentArchitecture() const +{ + // deb package names end in {DEBIAN_ARCHITECTURE}.deb + // pipedal build gets this value from `dpkg --print-architecture` +#ifndef DEBIAN_ARCHITECTURE // deb package names end in {DEBIAN_ARCHITECTURE}.deb +#error DEBIAN_ARCHITECTURE not defined +#endif + std::string downloadEnding = SS("_" << (DEBIAN_ARCHITECTURE) << ".deb"); + + for (auto &asset : assets) + { + if (asset.name.ends_with(downloadEnding)) + { + return &asset; + } + } + return nullptr; +} + +UpdateRelease::UpdateRelease() +{ +} + +JSON_MAP_BEGIN(UpdateRelease) +JSON_MAP_REFERENCE(UpdateRelease, updateAvailable) +JSON_MAP_REFERENCE(UpdateRelease, upgradeVersion) +JSON_MAP_REFERENCE(UpdateRelease, upgradeVersionDisplayName) +JSON_MAP_REFERENCE(UpdateRelease, assetName) +JSON_MAP_REFERENCE(UpdateRelease, updateUrl) +JSON_MAP_END(); + +JSON_MAP_BEGIN(UpdateStatus) +JSON_MAP_REFERENCE(UpdateStatus, lastUpdateTime) +JSON_MAP_REFERENCE(UpdateStatus, isValid) +JSON_MAP_REFERENCE(UpdateStatus, errorMessage) +JSON_MAP_REFERENCE(UpdateStatus, isOnline) +JSON_MAP_REFERENCE(UpdateStatus, currentVersion) +JSON_MAP_REFERENCE(UpdateStatus, currentVersionDisplayName) +JSON_MAP_REFERENCE(UpdateStatus, updatePolicy) +JSON_MAP_REFERENCE(UpdateStatus, releaseOnlyRelease) +JSON_MAP_REFERENCE(UpdateStatus, releaseOrBetaRelease) +JSON_MAP_REFERENCE(UpdateStatus, devRelease) +JSON_MAP_END(); diff --git a/src/Updater.hpp b/src/Updater.hpp new file mode 100644 index 0000000..aad6c13 --- /dev/null +++ b/src/Updater.hpp @@ -0,0 +1,145 @@ +// Copyright (c) 2024 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#pragma once + +#include +#include +#include +#include +#include +#include "json.hpp" +#include + +class GithubRelease; + +namespace pipedal +{ + + class Updater; + + enum class UpdatePolicyT + { + // ordinal values must not change, as files depend on them. + // must match declaration in Updater.tsx + + ReleaseOnly = 0, + ReleaseOrBeta = 1, + Development = 2, + }; + class UpdateRelease + { + private: + friend class Updater; + bool updateAvailable_ = false; + std::string upgradeVersion_; // just the version. + std::string upgradeVersionDisplayName_; // display name for the version. + std::string assetName_; // filename only + std::string updateUrl_; // url from which to download the .deb file. + public: + UpdateRelease(); + + bool UpdateAvailable() const { return updateAvailable_; } + const std::string &UpgradeVersion() const { return upgradeVersion_; } + const std::string &UpgradeVersionDisplayName() const { return upgradeVersionDisplayName_; } + const std::string &AssetName() const { return assetName_; } + const std::string &UpdateUrl() const { return updateUrl_; } + bool operator==(const UpdateRelease &other) const; + DECLARE_JSON_MAP(UpdateRelease); + }; + + class UpdateStatus + { + private: + friend class Updater; + std::chrono::system_clock::time_point::rep lastUpdateTime_ = 0; + + bool isValid_ = false; + std::string errorMessage_; + bool isOnline_ = false; + std::string currentVersion_; + std::string currentVersionDisplayName_; + + int32_t updatePolicy_ = (int32_t)(UpdatePolicyT::ReleaseOrBeta); + UpdateRelease releaseOnlyRelease_; + UpdateRelease releaseOrBetaRelease_; + UpdateRelease devRelease_; + + public: + UpdateStatus(); + std::chrono::system_clock::time_point LastUpdateTime() const; + void LastUpdateTime(const std::chrono::system_clock::time_point &timePoint); + + bool IsValid() const { return isValid_; } + const std::string &ErrorMessage() const { return errorMessage_; } + bool IsOnline() const { return isOnline_; } + const std::string &CurrentVersion() const { return currentVersion_; } + const std::string &CurrentDisplayVersion() const { return currentVersionDisplayName_; } + UpdatePolicyT UpdatePolicy() const { return (UpdatePolicyT)updatePolicy_; } + void UpdatePolicy(UpdatePolicyT updatePreference) { this->updatePolicy_ = (int32_t)updatePreference; } + + const UpdateRelease &ReleaseOnlyRelease() const { return releaseOnlyRelease_; } + const UpdateRelease &ReleaseOrBetaRelease() const { return releaseOrBetaRelease_; } + const UpdateRelease &DevRelease() const { return devRelease_; } + bool operator==(const UpdateStatus &other) const; + + DECLARE_JSON_MAP(UpdateStatus); + }; + + class Updater + { + public: + Updater(); + ~Updater(); + using UpdateListener = std::function; + + void SetUpdateListener(UpdateListener &&listener); + void CheckNow(); + void Stop(); + + UpdatePolicyT GetUpdatePolicy(); + void SetUpdatePolicy(UpdatePolicyT updatePolicy); + void ForceUpdateCheck(); + private: + UpdatePolicyT updatePolicy = UpdatePolicyT::ReleaseOrBeta; + using UpdateReleasePredicate = std::function; + + UpdateRelease getUpdateRelease( + const std::vector &githubReleases, + const std::string ¤tVersion, + const UpdateReleasePredicate &predicate); + + UpdateStatus cachedUpdateStatus; + bool stopped = false; + using clock = std::chrono::steady_clock; + + int event_reader = -1; + int event_writer = -1; + void ThreadProc(); + void CheckForUpdate(bool useCache); + UpdateListener listener; + + std::unique_ptr thread; + std::mutex mutex; + + bool hasInfo = false; + UpdateStatus currentResult; + static clock::duration updateRate; + }; +} \ No newline at end of file diff --git a/src/UpdaterTest.cpp b/src/UpdaterTest.cpp new file mode 100644 index 0000000..7262ae9 --- /dev/null +++ b/src/UpdaterTest.cpp @@ -0,0 +1,52 @@ +// Copyright (c) 2022 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "pch.h" +#include "catch.hpp" +#include "Updater.hpp" +#include "json.hpp" + +using namespace std; + +using namespace pipedal; + +TEST_CASE( "updater test", "[updater]" ) { + int nCalls = 0; + cout << "------ upater test ----" << endl; + { Updater updater; + + + updater.SetUpdateListener( + [&nCalls](const UpdateStatus&updateStatus) mutable + { + + cout << "updateStatus:" << endl; + json_writer writer(cout,false); + writer.write(updateStatus); + cout << endl; + ++nCalls; + } + ); + } + REQUIRE(nCalls == 1); + + + + +} \ No newline at end of file diff --git a/src/jsonTest.cpp b/src/jsonTest.cpp index e71a6c6..cdf88ff 100644 --- a/src/jsonTest.cpp +++ b/src/jsonTest.cpp @@ -158,11 +158,40 @@ TEST_CASE("json smart ptrs", "[json_smart_ptrs][Build][Dev]") } } +template +U & VariantAs(json_variant& v) +{ + throw std::runtime_error("Missing specialization."); +} + + template <> + inline json_null &VariantAs(json_variant& v) { return v.as_null(); } + + template <> + inline bool &VariantAs(json_variant& v) { return v.as_bool(); } + + template <> + inline double &VariantAs(json_variant& v) { return v.as_number(); } + + template <> + inline std::string &VariantAs(json_variant& v) { return v.as_string(); } + + template <> + inline std::shared_ptr &VariantAs>(json_variant& v) { return v.as_object(); } + + template <> + inline std::shared_ptr &VariantAs>(json_variant& v) { return v.as_array(); } + + template <> + inline json_variant &VariantAs(json_variant& v) { return v; } + + + template void TestVariantRoundTrip(const T &value) { json_variant variant(value); - T out = variant.as(); + T &out = VariantAs(variant); REQUIRE(out == value); std::string output; diff --git a/test/UriTest.cpp b/test/UriTest.cpp index 5f9c3a8..a050bc3 100644 --- a/test/UriTest.cpp +++ b/test/UriTest.cpp @@ -22,7 +22,7 @@ #include #include -using namespace piddle; +using namespace pipedal; TEST_CASE( "uri test", "[uri]" ) {