diff options
Diffstat (limited to 'src/downloadmanager.h')
| -rw-r--r-- | src/downloadmanager.h | 307 |
1 files changed, 240 insertions, 67 deletions
diff --git a/src/downloadmanager.h b/src/downloadmanager.h index a65d4fcb..9c8e391d 100644 --- a/src/downloadmanager.h +++ b/src/downloadmanager.h @@ -24,6 +24,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <QElapsedTimer> #include <QFile> #include <QFileSystemWatcher> +#include <QHash> #include <QMap> #include <QNetworkReply> #include <QObject> @@ -31,7 +32,6 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <QSettings> #include <QStringList> #include <QTime> -#include <QTimer> #include <QUrl> #include <QVector> #include <boost/accumulators/accumulators.hpp> @@ -40,6 +40,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include <boost/signals2.hpp> #include <idownloadmanager.h> #include <modrepositoryfileinfo.h> +#include <optional> #include <set> using namespace boost::accumulators; @@ -52,6 +53,60 @@ class NexusInterface; class PluginContainer; class OrganizerCore; +/** + * @brief QFileSystemWatcher with a nestable RAII suspension scope. + * + * Forwards directoryChanged() only while no Guard is alive. Use a Guard to + * bracket filesystem writes that would otherwise trigger a spurious refresh. + */ +class DirWatcherManager : public QObject +{ + Q_OBJECT + +public: + explicit DirWatcherManager(QObject* parent = nullptr); + + /// Set the directory being watched (replaces any previous path). + void setPath(const QString& path); + + /// True while one or more Guards are alive. + bool isSuspended() const; + + /** + * @brief RAII suspension guard. Nests safely; the only way to suspend + * forwarding. + */ + class [[nodiscard]] Guard + { + public: + explicit Guard(DirWatcherManager& manager); + ~Guard(); + Guard(const Guard&) = delete; + Guard& operator=(const Guard&) = delete; + Guard(Guard&&) = delete; + Guard& operator=(Guard&&) = delete; + + private: + DirWatcherManager& m_manager; + }; + + /// Returns a suspension Guard bound to the caller's scope. + [[nodiscard]] Guard scopedGuard(); + +signals: + /// Emitted when the watched directory changes and no Guard is active. + void directoryChanged(); + +private slots: + void onDirectoryChanged(const QString&); + +private: + void releaseSuspension(); + + QFileSystemWatcher m_watcher; + int m_suspendDepth = 0; +}; + /*! * \brief manages downloading of files and provides progress information for gui *elements @@ -61,6 +116,25 @@ class DownloadManager : public QObject Q_OBJECT public: + /** + * @brief RAII full-reset guard. Use when the row count changes; drops + * view selection/scroll state. Nests safely: inner guards coalesce into + * the outermost scope so only one reset is emitted. + */ + class [[nodiscard]] ModelResetGuard + { + public: + explicit ModelResetGuard(DownloadManager& manager); + ~ModelResetGuard(); + ModelResetGuard(const ModelResetGuard&) = delete; + ModelResetGuard& operator=(const ModelResetGuard&) = delete; + ModelResetGuard(ModelResetGuard&&) = delete; + ModelResetGuard& operator=(ModelResetGuard&&) = delete; + + private: + DownloadManager& m_manager; + }; + enum DownloadState { STATE_STARTED = 0, @@ -79,6 +153,32 @@ public: STATE_UNINSTALLED }; + /** + * @brief Stable identifier for a download. + * + * Monotonically increasing within a session and never reused. Distinct from + * row indices, which are positional and shift as the list is mutated. + */ + using DownloadID = unsigned int; + + /** + * @brief A download that has been requested but has not yet produced a + * DownloadInfo. + * + * Created when the user initiates an NXM download; drained either when the + * Nexus API returns the actual download URL (at which point a DownloadInfo + * is created using reservedID as its download id so that external references + * handed out before the download existed remain valid) or when the request + * is cancelled or fails. + */ + struct PendingDownload + { + QString gameName; + int modID; + int fileID; + DownloadID reservedID; + }; + private: struct DownloadInfo { @@ -87,7 +187,7 @@ private: accumulator_set<qint64, stats<tag::rolling_mean>> m_DownloadTimeAcc; qint64 m_DownloadLast; qint64 m_DownloadTimeLast; - unsigned int m_DownloadID; + DownloadID m_DownloadID; QString m_FileName; QFile m_Output; QNetworkReply* m_Reply; @@ -116,8 +216,26 @@ private: bool m_Hidden; + /** + * @brief Issue a new download id. + * + * The only supported way to obtain one; ids are monotonically increasing + * within a session and never reused. + */ + static DownloadID newDownloadID(); + + /** + * @brief Create a new DownloadInfo for a fresh download. + * + * When reservedID is provided it is used as the download id. Callers that + * need to hand out an id before the DownloadInfo exists (e.g. the NXM flow + * reserves an id when the request is queued, long before the Nexus API + * returns the actual URL) should reserve via newDownloadID() and pass it + * here. Otherwise a fresh id is drawn internally. + */ static DownloadInfo* createNew(const MOBase::ModRepositoryFileInfo* fileInfo, - const QStringList& URLs); + const QStringList& URLs, + std::optional<DownloadID> reservedID = {}); static DownloadInfo* createFromMeta(const QString& filePath, bool showHidden, const QString outputDirectory, std::optional<uint64_t> fileSize = {}); @@ -132,14 +250,14 @@ private: **/ void setName(QString newName, bool renameFile); - unsigned int downloadID() { return m_DownloadID; } + DownloadID downloadID() { return m_DownloadID; } bool isPausedState(); QString currentURL(); private: - static unsigned int s_NextDownloadID; + static DownloadID s_NextDownloadID; private: DownloadInfo() @@ -192,19 +310,6 @@ public: void setOutputDirectory(const QString& outputDirectory, const bool refresh = true); /** - * @brief disables feedback from the downlods fileSystemWhatcher untill - *disableDownloadsWatcherEnd() is called - * - **/ - static void startDisableDirWatcher(); - - /** - * @brief re-enables feedback from the downlods fileSystemWhatcher after - *disableDownloadsWatcherStart() was called - **/ - static void endDisableDirWatcher(); - - /** * @return current download directory **/ QString getOutputDirectory() const { return m_OutputDirectory; } @@ -239,18 +344,22 @@ public: bool addDownload(QNetworkReply* reply, const QStringList& URLs, const QString& fileName, QString gameName, int modID, int fileID = 0, const MOBase::ModRepositoryFileInfo* fileInfo = - new MOBase::ModRepositoryFileInfo()); + new MOBase::ModRepositoryFileInfo(), + std::optional<DownloadID> reservedID = {}); /** * @brief start a download using a nxm-link * - * starts a download using a nxm-link. The download manager will first query the nexus - * page for file information. + * Starts a download using a nxm-link. The download manager will first query the + * nexus page for file information. The returned id identifies the eventual + * download; it is reserved immediately so external references remain valid even + * before the Nexus API responds. * @param url a nxm link looking like this: nxm://skyrim/mods/1234/files/4711 + * @return the reserved download id * @todo the game name encoded into the link is currently ignored, all downloads are *incorrectly assumed to be for the identified game **/ - void addNXMDownload(const QString& url); + DownloadID addNXMDownload(const QString& url); /** * @brief retrieve the total number of downloads, both finished and unfinished @@ -271,9 +380,24 @@ public: * @brief retrieve the info of a pending download * @param index index of the pending download (index in the range [0, * numPendingDownloads()[) - * @return pair of modid, fileid + * @return the PendingDownload entry at the given index + */ + PendingDownload getPendingDownload(int index); + + /** + * @brief Resolve a view row to a stable DownloadID. + * + * Rows cover active downloads followed by pending ones. Returns 0 if the row + * is out of range. */ - std::tuple<QString, int, int> getPendingDownload(int index); + DownloadID downloadIDAtRow(int row) const; + + /** + * @brief Resolve a stable DownloadID to its current view row. + * + * @return the current row, or -1 if no download with that id is tracked. + */ + int rowForDownloadID(DownloadID id) const; /** * @brief retrieve the full path to the download specified by index @@ -408,6 +532,12 @@ public: */ void queryDownloadListInfo(); + /** + * @return the directory watcher for the downloads folder; call + * scopedGuard() on it to suspend across filesystem writes. + */ + DirWatcherManager& dirWatcher() { return m_DirWatcher; } + public: // IDownloadManager interface: int startDownloadURLs(const QStringList& urls); int startDownloadURLWithMeta(const QString& url, const QString& game, @@ -435,16 +565,38 @@ public: // IDownloadManager interface: void pauseAll(); + /** + * @brief notify the UI that a single row's data changed. Preserves view + * state; prefer over ModelResetGuard when the row count is unchanged. + * + * @param row the row that changed. This corresponds to the download index + */ + void notifyRowChanged(int row); + Q_SIGNALS: - void aboutToUpdate(); + /** + * @brief emitted before the download list model is about to be reset + * + * Emitted by ModelResetGuard on construction. Views should call + * beginResetModel() in response. + */ + void aboutToResetModel(); /** - * @brief signals that the specified download has changed + * @brief emitted after the download list model has been reset + * + * Emitted by ModelResetGuard on destruction. Views should call + * endResetModel() in response. + */ + void modelReset(); + + /** + * @brief signals that the specified download row's data has changed * * @param row the row that changed. This corresponds to the download index - **/ - void update(int row); + */ + void rowChanged(int row); /** * @brief signals the ui that a message should be displayed @@ -492,13 +644,13 @@ public slots: * @brief cancel the specified download. This will lead to the corresponding file to *be deleted * - * @param index index of the download to cancel + * @param id id of the download to cancel **/ - void cancelDownload(int index); + void cancelDownload(DownloadID id); - void pauseDownload(int index); + void pauseDownload(DownloadID id); - void resumeDownload(int index); + void resumeDownload(DownloadID id); void queryInfo(int index); @@ -538,11 +690,23 @@ private slots: void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); void downloadReadyRead(); - void downloadFinished(int index = 0); + /** + * @brief Slot wired to QNetworkReply::finished(). + * + * Resolves the originating reply through sender() and then dispatches to + * finishDownload. Use the public finishDownload directly for non-slot calls. + */ + void onReplyFinished(); + + /** + * @brief Run the post-download bookkeeping for the given download. + * + * Writes any remaining data, transitions the download's state, and emits + * the appropriate plugin signals. + */ + void finishDownload(DownloadID id); void downloadError(QNetworkReply::NetworkError error); void metaDataChanged(); - void directoryChanged(const QString& dirctory); - void checkDownloadTimeout(); private: void createMetaFile(DownloadInfo* info); @@ -561,8 +725,15 @@ public: QString getDownloadFileName(const QString& baseName, bool rename = false) const; private: - void startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume); - void resumeDownloadInt(int index); + /** + * @brief Begin downloading into newDownload from reply. + * + * On the !resume path newDownload becomes owned by m_ActiveDownloads on + * success; on failure (e.g. the output file cannot be opened) it is deleted + * before returning. Returns whether the download actually started. + */ + bool startDownload(QNetworkReply* reply, DownloadInfo* newDownload, bool resume); + void resumeDownloadInt(DownloadID id); /** * @brief start a download from a url @@ -573,7 +744,8 @@ private: *only happens if there is a duplicate and the user decides not to download again **/ bool addDownload(const QStringList& URLs, QString gameName, int modID, int fileID, - const MOBase::ModRepositoryFileInfo* fileInfo); + const MOBase::ModRepositoryFileInfo* fileInfo, + std::optional<DownloadID> reservedID = {}); // important: the caller has to lock the list-mutex, otherwise the // DownloadInfo-pointer might get invalidated at any time @@ -581,22 +753,38 @@ private: void removeFile(int index, bool deleteFile); - void refreshAlphabeticalTranslation(); - - bool ByName(int LHS, int RHS); - QString getFileNameFromNetworkReply(QNetworkReply* reply); void setState(DownloadInfo* info, DownloadManager::DownloadState state); - DownloadInfo* downloadInfoByID(unsigned int id); + DownloadInfo* downloadInfoByID(DownloadID id); void removePending(QString gameName, int modID, int fileID); + /** + * @brief Fire onDownloadFailed for a pending entry, if any matches. + * + * Used on Nexus API failures so callers holding a reserved id from + * addNXMDownload do not wait indefinitely for a result. No-op if no pending + * entry matches the (gameName, modID, fileID) triple. + */ + void notifyPendingDownloadFailed(const QString& gameName, int modID, int fileID); + + /** + * @brief Roll back a download that has not yet been activated. + * + * Ensures a caller awaiting the reservedID receives an onDownloadFailed + * callback. Must not be called once the download has been registered as + * active. + */ + void cancelPendingDownload(DownloadInfo* newDownload, QNetworkReply* reply); + static QString getFileTypeString(int fileType); void writeData(DownloadInfo* info); + QString getValidGameShortName(const QString& gameNexusName) const; + private: static const int AUTOMATIC_RETRIES = 3; @@ -606,45 +794,30 @@ private: OrganizerCore* m_OrganizerCore; QWidget* m_ParentWidget; - QVector<std::tuple<QString, int, int>> m_PendingDownloads; + QVector<PendingDownload> m_PendingDownloads; QVector<DownloadInfo*> m_ActiveDownloads; + // Secondary index into m_ActiveDownloads keyed by m_DownloadID; kept in sync + // with every m_ActiveDownloads mutation. + QHash<DownloadID, DownloadInfo*> m_ByID; + QString m_OutputDirectory; std::set<int> m_RequestIDs; - QVector<int> m_AlphabeticalTranslation; - QFileSystemWatcher m_DirWatcher; + DirWatcherManager m_DirWatcher; + + // nesting depth of active ModelResetGuard scopes; see its docs + int m_modelResetDepth = 0; SignalDownloadCallback m_DownloadComplete; SignalDownloadCallback m_DownloadPaused; SignalDownloadCallback m_DownloadFailed; SignalDownloadCallback m_DownloadRemoved; - // The dirWatcher is actually triggering off normal Mo operations such as deleting - // downloads or editing .meta files so it needs to be disabled during operations that - // are known to cause the creation or deletion of files in the Downloads folder. - // Notably using QSettings to edit a file creates a temporarily .lock file that causes - // the Watcher to trigger multiple listRefreshes freezing the ui. - static int m_DirWatcherDisabler; - - std::map<QString, int> m_DownloadFails; - bool m_ShowHidden; MOBase::IPluginGame const* m_ManagedGame; - - QTimer m_TimeoutTimer; -}; - -class ScopedDisableDirWatcher -{ -public: - ScopedDisableDirWatcher(DownloadManager* downloadManager); - ~ScopedDisableDirWatcher(); - -private: - DownloadManager* m_downloadManager; }; #endif // DOWNLOADMANAGER_H |
