Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2025 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "appupdateservice.h"
24 :
25 : #include <QBuffer>
26 : #include <QColor>
27 : #include <QJsonParseError>
28 : #include <QJsonObject>
29 : #include <QJsonValue>
30 : #include <QJsonArray>
31 : #include <QJsonDocument>
32 : #include <QUrlQuery>
33 :
34 : #include "update/updateerrors.h"
35 :
36 : #include "downloadfiledevice.h"
37 :
38 : #include "async/async.h"
39 : #include "global/concurrency/concurrent.h"
40 : #include "runtime.h"
41 : #include "defer.h"
42 : #include "translation.h"
43 : #include "log.h"
44 :
45 : using namespace muse;
46 : using namespace muse::update;
47 : using namespace muse::network;
48 : using namespace muse::async;
49 : using namespace muse::io;
50 :
51 : const QString INSTALLED_WEEK_BEGINNING_KEY("Installed-Week-Beginning");
52 : const QString PREVIOUS_REQUEST_DAY_KEY("Previous-Request-Day");
53 :
54 : static const std::string PARTIAL_SUFFIX(".part");
55 :
56 : //! NOTE: In-place install unpacks the package next to it, so reserve room for
57 : //! the unpacked app on top of the package itself.
58 : static constexpr uint64_t UNPACK_SIZE_FACTOR = 2;
59 : static constexpr uint64_t DISK_SPACE_RESERVE = 100ull * 1024 * 1024;
60 :
61 : static constexpr uint64_t MAX_PACKAGE_SIZE = 500ull * 1024 * 1024;
62 :
63 : //! NOTE: The download takes the first 90% of the progress, the preparation
64 : //! for in-place install takes the rest.
65 : static constexpr int64_t PROGRESS_TOTAL = 1000;
66 : static constexpr int64_t PROGRESS_DOWNLOAD_PART = 900;
67 :
68 0 : static QDate calculateWeekBeginForDate(const QDate& date)
69 : {
70 : // 1 (Monday) + 6 mod 7 = 0
71 : // 2 (Tuesday) + 6 mod 7 = 1
72 : // etc...
73 0 : int diffToWeekBegin = (date.dayOfWeek() + 6) % 7;
74 0 : return QDate::currentDate().addDays(-diffToWeekBegin);
75 : }
76 :
77 6 : static ISystemInfo::CpuArchitecture assetArch(const QString& asset)
78 : {
79 6 : if (asset.contains("aarch64")) {
80 1 : return ISystemInfo::CpuArchitecture::Arm64;
81 5 : } else if (asset.contains("arm")) {
82 2 : return ISystemInfo::CpuArchitecture::Arm;
83 : }
84 :
85 3 : return ISystemInfo::CpuArchitecture::x86_64;
86 : }
87 :
88 7 : static PrevReleasesNotesList parsePreviousReleasesNotes(const QByteArray& json)
89 : {
90 7 : PrevReleasesNotesList result;
91 :
92 7 : QJsonParseError err;
93 7 : QJsonDocument jsonDoc = QJsonDocument::fromJson(json, &err);
94 7 : if (err.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
95 0 : LOGE() << "failed parse, err: " << err.errorString();
96 0 : return result;
97 : }
98 :
99 7 : QJsonObject jsonObject = jsonDoc.object();
100 :
101 7 : if (jsonObject.empty()) {
102 0 : LOGE() << "failed parse, no jsonObject";
103 0 : return result;
104 : }
105 :
106 7 : QJsonArray releases = jsonObject.value("releases").toArray();
107 35 : for (const QJsonValue& release : releases) {
108 28 : QJsonObject releaseObj = release.toObject();
109 28 : std::string version = releaseObj.value("version").toString().toStdString();
110 :
111 28 : result.push_back({ std::move(version), releaseObj.value("notes").toString().toStdString() });
112 28 : }
113 :
114 7 : return result;
115 7 : }
116 :
117 0 : void AppUpdateService::init()
118 : {
119 0 : TRACEFUNC;
120 :
121 0 : io::path_t historyPath = configuration()->updateRequestHistoryJsonPath();
122 0 : if (fileSystem()->exists(historyPath)) {
123 0 : return;
124 : }
125 :
126 : // If the request history file doesn't exist, perform a first time setup using today's date...
127 0 : UpdateRequestHistory updateRequestHistory;
128 0 : updateRequestHistory.installedWeekBeginning = calculateWeekBeginForDate(QDate::currentDate());
129 0 : updateRequestHistory.previousRequestDay = QDate::currentDate();
130 :
131 0 : Ret ret = writeUpdateRequestHistory(historyPath, updateRequestHistory);
132 0 : if (!ret) {
133 0 : LOGE() << ret.toString();
134 : }
135 0 : }
136 :
137 7 : Promise<RetVal<ReleaseInfo> > AppUpdateService::checkForUpdate()
138 : {
139 0 : return Promise<RetVal<ReleaseInfo> >([this](auto resolve, auto) {
140 7 : clear();
141 :
142 7 : if (!m_networkManager) {
143 7 : m_networkManager = networkManagerCreator()->makeNetworkManager();
144 : }
145 :
146 : // Read request history file...
147 7 : io::path_t historyPath = configuration()->updateRequestHistoryJsonPath();
148 7 : RetVal<UpdateRequestHistory> historyRv = readUpdateRequestHistory(historyPath);
149 7 : if (!historyRv.ret) {
150 7 : LOGE() << historyRv.ret.toString();
151 : }
152 :
153 7 : QUrl url = QString::fromStdString(configuration()->checkForAppUpdateUrl());
154 7 : if (requestParamsProvider()) {
155 0 : QUrlQuery query(url);
156 0 : for (const auto& [key, value] : requestParamsProvider()->updateRequestParams()) {
157 0 : query.addQueryItem(QString::fromStdString(key), QString::fromStdString(value));
158 : }
159 :
160 0 : if (!query.isEmpty()) {
161 0 : url.setQuery(query);
162 : }
163 0 : }
164 :
165 7 : RequestHeaders headers = prepareHeaders(historyRv.val);
166 7 : auto buff = std::make_shared<QBuffer>();
167 :
168 7 : RetVal<Progress> progress = m_networkManager->get(url, buff, headers);
169 7 : if (!progress.ret) {
170 0 : m_lastCheckResult.ret = progress.ret;
171 0 : return resolve(m_lastCheckResult);
172 : }
173 :
174 7 : progress.val.finished().onReceive(this, [this, historyPath, historyRv, buff, resolve](const ProgressResult& res) {
175 7 : if (!res.ret) {
176 0 : m_lastCheckResult.ret = res.ret;
177 0 : (void)resolve(m_lastCheckResult);
178 0 : return;
179 : }
180 :
181 : // Successfully performed request, update history file...
182 7 : UpdateRequestHistory updateRequestHistory = historyRv.val;
183 7 : updateRequestHistory.previousRequestDay = QDate::currentDate();
184 7 : Ret writeHistoryRet = writeUpdateRequestHistory(historyPath, updateRequestHistory);
185 7 : if (!writeHistoryRet) {
186 7 : LOGE() << writeHistoryRet.toString();
187 : }
188 :
189 7 : QByteArray json = buff->data();
190 7 : RetVal<ReleaseInfo> releaseInfo = parseRelease(json);
191 :
192 7 : if (!releaseInfo.ret) {
193 0 : m_lastCheckResult.ret = releaseInfo.ret;
194 0 : (void)resolve(m_lastCheckResult);
195 0 : return;
196 : }
197 :
198 7 : Version current(application()->fullVersion());
199 7 : Version update(releaseInfo.val.version);
200 :
201 7 : bool allowUpdateOnPreRelease = configuration()->allowUpdateOnPreRelease();
202 7 : bool isPreRelease = update.preRelease();
203 :
204 7 : if (!allowUpdateOnPreRelease && isPreRelease) {
205 0 : cleanupStalePackages(/*keepFileName*/ std::string());
206 0 : m_lastCheckResult.ret = make_ret(Err::NoUpdate);
207 0 : (void)resolve(m_lastCheckResult);
208 0 : return;
209 : }
210 :
211 7 : if (update <= current) {
212 0 : cleanupStalePackages(/*keepFileName*/ std::string());
213 0 : m_lastCheckResult.ret = make_ret(Err::NoUpdate);
214 0 : (void)resolve(m_lastCheckResult);
215 0 : return;
216 : }
217 :
218 7 : m_lastCheckResult = releaseInfo;
219 :
220 : //! NOTE: Keep an already-downloaded package for this release; drop stale ones.
221 7 : cleanupStalePackages(releaseInfo.val.fileName);
222 :
223 7 : downloadPreviousReleasesNotes(update, [this, resolve](const PrevReleasesNotesList& notes) {
224 7 : m_lastCheckResult.val.previousReleasesNotes = notes;
225 7 : (void)resolve(m_lastCheckResult);
226 : });
227 7 : });
228 :
229 7 : return Promise<RetVal<ReleaseInfo> >::dummy_result();
230 14 : });
231 : }
232 :
233 0 : const RetVal<ReleaseInfo>& AppUpdateService::lastCheckResult() const
234 : {
235 0 : return m_lastCheckResult;
236 : }
237 :
238 21 : RetVal<Progress> AppUpdateService::downloadRelease()
239 : {
240 21 : if (m_downloadInProgress) {
241 1 : return RetVal<Progress>::make_ok(m_updateProgress);
242 : }
243 :
244 20 : if (!m_networkManager) {
245 18 : m_networkManager = networkManagerCreator()->makeNetworkManager();
246 : }
247 :
248 20 : if (!m_lastCheckResult.ret) {
249 0 : return RetVal<Progress>::make_ret(m_lastCheckResult.ret);
250 : }
251 :
252 20 : const ReleaseInfo info = m_lastCheckResult.val;
253 20 : if (info.fileName.empty()) {
254 0 : return RetVal<Progress>::make_ret(make_ret(Err::NoUpdate));
255 : }
256 :
257 20 : const path_t finalPath = packagesDir() + "/" + info.fileName;
258 :
259 20 : m_updateProgress.canceled().disconnect(this);
260 :
261 : //! NOTE: Already downloaded (e.g. in a previous session), only the preparation is left
262 20 : if (fileSystem()->exists(finalPath)) {
263 4 : m_downloadInProgress = true;
264 4 : m_updateProgress.start();
265 4 : prepareUpdate(finalPath);
266 4 : return RetVal<Progress>::make_ok(m_updateProgress);
267 : }
268 :
269 16 : const QUrl fileUrl = QUrl::fromUserInput(QString::fromStdString(info.fileUrl));
270 16 : const path_t partialPath = finalPath + PARTIAL_SUFFIX;
271 16 : fileSystem()->makePath(muse::io::absoluteDirpath(partialPath));
272 :
273 : //! NOTE: Resume an interrupted download by appending to the partial file and
274 : //! requesting the remaining bytes via a Range header.
275 16 : uint64_t offset = 0;
276 16 : if (fileSystem()->exists(partialPath)) {
277 3 : RetVal<uint64_t> sz = fileSystem()->fileSize(partialPath);
278 3 : offset = sz.ret ? sz.val : 0;
279 3 : }
280 :
281 16 : Ret spaceRet = checkDiskSpace(DiskSpaceFor::Download, info.fileSize, offset);
282 16 : if (!spaceRet) {
283 2 : return RetVal<Progress>::make_ret(spaceRet);
284 : }
285 :
286 14 : configuration()->setLastDownloadedPackagePath(finalPath);
287 :
288 14 : RequestHeaders headers;
289 14 : io::IODevice::OpenMode mode = io::IODevice::WriteOnly;
290 14 : if (offset > 0) {
291 3 : headers.rawHeaders["Range"] = QByteArray("bytes=") + QByteArray::number(static_cast<qulonglong>(offset)) + "-";
292 3 : mode = io::IODevice::Append;
293 : }
294 :
295 14 : auto device = std::make_shared<DownloadFileDevice>(partialPath, mode);
296 :
297 14 : m_downloadInProgress = true;
298 :
299 14 : RetVal<Progress> downloadProgress = m_networkManager->get(fileUrl, device, headers);
300 14 : if (!downloadProgress.ret) {
301 0 : m_downloadInProgress = false;
302 0 : return RetVal<Progress>::make_ret(downloadProgress.ret);
303 : }
304 :
305 14 : m_updateProgress.start();
306 :
307 14 : m_updateProgress.canceled().onNotify(this, [this, downloadProgress]() {
308 1 : Progress mutProgress = downloadProgress.val;
309 1 : mutProgress.cancel();
310 1 : m_updateProgress.canceled().disconnect(this);
311 1 : }, Asyncable::Mode::SetReplace);
312 :
313 14 : downloadProgress.val.progressChanged().onReceive(this, [this, offset](int64_t current, int64_t total, const std::string& msg) {
314 1 : const int64_t downloaded = static_cast<int64_t>(offset) + current;
315 1 : const int64_t size = static_cast<int64_t>(offset) + total;
316 1 : if (size <= 0) {
317 0 : return;
318 : }
319 :
320 1 : m_updateProgress.progress(downloaded * PROGRESS_DOWNLOAD_PART / size, PROGRESS_TOTAL, msg);
321 : }, Asyncable::Mode::SetReplace);
322 :
323 14 : downloadProgress.val.finished().onReceive(this, [this, finalPath, partialPath, offset](const ProgressResult& res) {
324 7 : if (!res.ret) {
325 : //! NOTE: Keep the partial file so the next attempt can resume from it.
326 2 : m_downloadInProgress = false;
327 2 : m_updateProgress.finish(ProgressResult::make_ret(res.ret));
328 3 : return;
329 : }
330 :
331 10 : const int status = res.ret.data<int>("status", 0);
332 :
333 : //! NOTE: We requested a range but the server sent the full file (200) or
334 : //! rejected the range (416); the partial file is now stale/corrupt - drop
335 : //! it so the next attempt starts clean.
336 5 : if (offset > 0 && (status == 200 || status == 416)) {
337 1 : fileSystem()->remove(partialPath);
338 1 : m_downloadInProgress = false;
339 2 : m_updateProgress.finish(ProgressResult::make_ret(make_ret(Err::NetworkError, "range request not honoured")));
340 1 : return;
341 : }
342 :
343 : //! Success (200 fresh download or 206 resumed): promote the partial file
344 : //! to the final package name.
345 4 : const Ret ret = fileSystem()->move(partialPath, finalPath, /*replace*/ true);
346 4 : if (!ret) {
347 0 : m_downloadInProgress = false;
348 0 : m_updateProgress.finish(ProgressResult::make_ret(ret));
349 0 : return;
350 : }
351 :
352 4 : m_updateProgress.canceled().disconnect(this);
353 4 : prepareUpdate(finalPath);
354 4 : }, Asyncable::Mode::SetReplace);
355 :
356 14 : return RetVal<Progress>::make_ok(m_updateProgress);
357 20 : }
358 :
359 8 : void AppUpdateService::prepareUpdate(const muse::io::path_t& packagePath)
360 : {
361 8 : resetPreparedUpdate();
362 :
363 8 : async::Async::call(this, [this, packagePath]() {
364 7 : if (m_updateProgress.isCanceled()) {
365 0 : m_downloadInProgress = false;
366 2 : return;
367 : }
368 :
369 7 : if (!canAutoInstall()) {
370 1 : m_downloadInProgress = false;
371 1 : m_updateProgress.progress(PROGRESS_TOTAL, PROGRESS_TOTAL);
372 1 : m_updateProgress.finish(ProgressResult::make_ok(Val(packagePath)));
373 1 : return;
374 : }
375 :
376 6 : m_updateProgress.progress(PROGRESS_DOWNLOAD_PART, PROGRESS_TOTAL,
377 6 : muse::qtrc("update", "Preparing %1 %2")
378 12 : .arg(application()->title().toQString(), QString::fromStdString(m_lastCheckResult.val.version))
379 12 : .toStdString());
380 :
381 6 : RetVal<uint64_t> packageSize = fileSystem()->fileSize(packagePath);
382 6 : const Ret spaceRet = checkDiskSpace(DiskSpaceFor::Unpack, packageSize.ret ? packageSize.val : 0);
383 6 : if (!spaceRet) {
384 1 : m_downloadInProgress = false;
385 1 : m_updateProgress.finish(ProgressResult::make_ret(spaceRet));
386 1 : return;
387 : }
388 :
389 5 : auto installer = updateInstaller();
390 :
391 5 : Concurrent::run([this, installer, packagePath]() {
392 5 : const RetVal<io::path_t> prepared = installer->prepareUpdate(packagePath);
393 :
394 5 : async::Async::call(this, [this, packagePath, prepared]() {
395 5 : m_downloadInProgress = false;
396 :
397 5 : if (m_updateProgress.isCanceled()) {
398 1 : if (prepared.ret && prepared.val != packagePath) {
399 1 : fileSystem()->remove(prepared.val);
400 : }
401 1 : return;
402 : }
403 :
404 4 : if (prepared.ret) {
405 3 : m_preparedPackagePath = packagePath;
406 3 : m_preparedPath = prepared.val;
407 : } else {
408 : //! NOTE: The package is downloaded anyway, so it can still be installed manually
409 1 : LOGE() << "failed to prepare update: " << prepared.ret.toString();
410 : }
411 :
412 4 : m_updateProgress.progress(PROGRESS_TOTAL, PROGRESS_TOTAL);
413 4 : m_updateProgress.finish(ProgressResult::make_ok(Val(packagePath)));
414 5 : }, runtime::mainThreadId());
415 5 : });
416 7 : });
417 8 : }
418 :
419 8 : muse::io::path_t AppUpdateService::preparedUpdatePath() const
420 : {
421 8 : if (m_preparedPath.empty() || m_preparedPackagePath != downloadedReleasePath()) {
422 4 : return {};
423 : }
424 :
425 : //! NOTE: The prepared files may have been removed since
426 4 : if (!fileSystem()->exists(m_preparedPath)) {
427 0 : return {};
428 : }
429 :
430 4 : return m_preparedPath;
431 : }
432 :
433 16 : void AppUpdateService::resetPreparedUpdate()
434 : {
435 16 : m_preparedPackagePath = io::path_t();
436 16 : m_preparedPath = io::path_t();
437 16 : }
438 :
439 22 : Ret AppUpdateService::checkDiskSpace(DiskSpaceFor purpose, uint64_t packageSize, uint64_t downloadedBytes) const
440 : {
441 22 : if (packageSize == 0) {
442 17 : return make_ok();
443 : }
444 :
445 5 : if (packageSize > MAX_PACKAGE_SIZE) {
446 1 : LOGW() << "package size is not sane: " << packageSize;
447 1 : return make_ret(Err::NotEnoughDiskSpace);
448 : }
449 :
450 4 : io::path_t dir;
451 4 : uint64_t required = DISK_SPACE_RESERVE;
452 :
453 4 : switch (purpose) {
454 3 : case DiskSpaceFor::Download:
455 3 : dir = packagesDir();
456 3 : required += packageSize - std::min(downloadedBytes, packageSize);
457 3 : if (canAutoInstall()) {
458 0 : required += packageSize * UNPACK_SIZE_FACTOR;
459 : }
460 3 : break;
461 1 : case DiskSpaceFor::Unpack:
462 1 : dir = configuration()->updateDataPath();
463 1 : required += packageSize * UNPACK_SIZE_FACTOR;
464 1 : break;
465 : }
466 :
467 8 : RetVal<uint64_t> available = fileSystem()->availableSpace(dir);
468 4 : if (!available.ret) {
469 0 : LOGW() << "failed to query available disk space: " << available.ret.toString();
470 0 : return make_ok();
471 : }
472 :
473 4 : if (available.val >= required) {
474 2 : return make_ok();
475 : }
476 :
477 2 : LOGW() << "not enough disk space for update: required " << required << " bytes, available " << available.val;
478 :
479 2 : const uint64_t mb = 1024 * 1024;
480 2 : const uint64_t missingMb = (required - available.val + mb - 1) / mb;
481 2 : const std::string text = muse::qtrc("update", "Free up at least %1 MB of disk space and try again.")
482 4 : .arg(QString::number(static_cast<qulonglong>(missingMb))).toStdString();
483 :
484 2 : return make_ret(Err::NotEnoughDiskSpace, text);
485 4 : }
486 :
487 7 : RequestHeaders AppUpdateService::prepareHeaders(const UpdateRequestHistory& history) const
488 : {
489 7 : RequestHeaders headers = configuration()->updateHeaders();
490 :
491 7 : if (history.isValid()) {
492 0 : std::string iwbString = history.installedWeekBeginning.toString(Qt::ISODate).toStdString();
493 0 : QByteArray iwbKey = QByteArray::fromStdString(INSTALLED_WEEK_BEGINNING_KEY.toStdString());
494 0 : headers.rawHeaders[iwbKey] = QByteArray::fromStdString(iwbString);
495 :
496 0 : std::string prdString = history.previousRequestDay.toString(Qt::ISODate).toStdString();
497 0 : QByteArray prdKey = QByteArray::fromStdString(PREVIOUS_REQUEST_DAY_KEY.toStdString());
498 0 : headers.rawHeaders[prdKey] = QByteArray::fromStdString(prdString);
499 0 : }
500 :
501 7 : return headers;
502 0 : }
503 :
504 7 : RetVal<AppUpdateService::UpdateRequestHistory> AppUpdateService::readUpdateRequestHistory(const io::path_t& path) const
505 : {
506 7 : RetVal<ByteArray> rv = fileSystem()->readFile(path);
507 7 : if (!rv.ret) {
508 7 : return RetVal<UpdateRequestHistory>(rv.ret);
509 : }
510 :
511 0 : QJsonDocument historyDoc = QJsonDocument::fromJson(rv.val.toQByteArrayNoCopy());
512 0 : QJsonObject historyObject = historyDoc.object();
513 :
514 0 : UpdateRequestHistory updateRequestHistory;
515 :
516 0 : QString installedWeekBeginning = historyObject.value(INSTALLED_WEEK_BEGINNING_KEY).toString();
517 0 : updateRequestHistory.installedWeekBeginning = QDate::fromString(installedWeekBeginning, Qt::ISODate);
518 :
519 0 : QString previousRequestDay = historyObject.value(PREVIOUS_REQUEST_DAY_KEY).toString();
520 0 : updateRequestHistory.previousRequestDay = QDate::fromString(previousRequestDay, Qt::ISODate);
521 :
522 0 : return RetVal<UpdateRequestHistory>::make_ok(updateRequestHistory);
523 7 : }
524 :
525 7 : Ret AppUpdateService::writeUpdateRequestHistory(const io::path_t& path, const UpdateRequestHistory& updateRequestHistory)
526 : {
527 7 : if (!updateRequestHistory.isValid()) {
528 7 : return make_ret(Ret::Code::UnknownError);
529 : }
530 :
531 0 : QJsonObject historyObject;
532 :
533 0 : const QDate& iwb = updateRequestHistory.installedWeekBeginning;
534 0 : const QDate& prd = updateRequestHistory.previousRequestDay;
535 :
536 0 : historyObject[INSTALLED_WEEK_BEGINNING_KEY] = iwb.toString(Qt::ISODate);
537 0 : historyObject[PREVIOUS_REQUEST_DAY_KEY] = prd.toString(Qt::ISODate);
538 :
539 0 : QByteArray byteArray = QJsonDocument(historyObject).toJson();
540 :
541 0 : return fileSystem()->writeFile(path, ByteArray::fromQByteArrayNoCopy(byteArray));
542 0 : }
543 :
544 7 : RetVal<ReleaseInfo> AppUpdateService::parseRelease(const QByteArray& json) const
545 : {
546 7 : QJsonParseError err;
547 7 : QJsonDocument jsonDoc = QJsonDocument::fromJson(json, &err);
548 7 : if (err.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
549 0 : return make_ret(Err::ReleaseInfoParseError, err.errorString().toStdString());
550 : }
551 :
552 7 : QJsonObject release = jsonDoc.object();
553 7 : QJsonObject assetObj = resolveReleaseAsset(release);
554 7 : if (assetObj.empty()) {
555 0 : return make_ret(Err::ReleaseInfoParseError, "No release asset");
556 : }
557 :
558 7 : QString tagName = release.value("tag_name").toString();
559 7 : QString version = tagName.replace("v", "");
560 7 : if (version.isEmpty()) {
561 0 : return make_ret(Err::ReleaseInfoParseError, "No release version");
562 : }
563 :
564 7 : RetVal<ReleaseInfo> result;
565 7 : result.ret = muse::make_ok();
566 7 : result.val.fileName = assetObj.value("name").toString().toStdString();
567 7 : result.val.fileUrl = assetObj.value("browser_download_url").toString().toStdString();
568 7 : result.val.fileSize = static_cast<uint64_t>(std::max<qint64>(assetObj.value("size").toInteger(), 0));
569 7 : result.val.version = version.toStdString();
570 7 : result.val.notes = release.value("bodyMarkdown").toString().toStdString();
571 :
572 7 : return result;
573 7 : }
574 :
575 7 : std::vector<std::string> AppUpdateService::platformFileSuffixes() const
576 : {
577 7 : switch (systemInfo()->productType()) {
578 3 : case ISystemInfo::ProductType::Windows: return { "msi" };
579 3 : case ISystemInfo::ProductType::MacOS: return { "dmg" };
580 15 : case ISystemInfo::ProductType::Linux: return { "appimage" };
581 0 : case ISystemInfo::ProductType::Unknown: break;
582 : }
583 :
584 0 : return {};
585 : }
586 :
587 7 : QJsonObject AppUpdateService::resolveReleaseAsset(const QJsonObject& release) const
588 : {
589 7 : const std::vector<std::string> fileSuffixes = platformFileSuffixes();
590 7 : ISystemInfo::ProductType productType = systemInfo()->productType();
591 7 : ISystemInfo::CpuArchitecture arch = systemInfo()->cpuArchitecture();
592 :
593 7 : QJsonArray assets = release.value("assets").toArray();
594 21 : for (const QJsonValue& asset : release.value("assetsNew").toArray()) {
595 14 : assets.push_back(asset);
596 21 : }
597 :
598 : // Honour suffix priority: scan all assets for the most preferred suffix
599 : // before considering the next one.
600 7 : for (const std::string& fileSuffix : fileSuffixes) {
601 27 : for (const QJsonValue asset : assets) {
602 27 : QJsonObject assetObj = asset.toObject();
603 :
604 27 : QString name = assetObj.value("name").toString();
605 27 : if (io::suffix(name) != fileSuffix) {
606 17 : continue;
607 : }
608 :
609 10 : if (productType == ISystemInfo::ProductType::Linux) {
610 8 : if (arch != ISystemInfo::CpuArchitecture::Unknown && arch != assetArch(name)) {
611 3 : continue;
612 : }
613 : }
614 :
615 7 : return assetObj;
616 74 : }
617 : }
618 :
619 0 : return QJsonObject();
620 7 : }
621 :
622 51 : bool AppUpdateService::canAutoInstall() const
623 : {
624 51 : return updateInstaller()->isInPlaceUpdateSupported();
625 : }
626 :
627 2 : Ret AppUpdateService::installUpdate()
628 : {
629 2 : const io::path_t preparedPath = preparedUpdatePath();
630 2 : if (preparedPath.empty()) {
631 1 : LOGE() << "the update is not ready to install";
632 1 : return make_ret(Err::UnknownError);
633 : }
634 :
635 1 : return updateInstaller()->finalizeUpdate(preparedPath, makeInstallProgressUi());
636 2 : }
637 :
638 1 : InstallProgressUi AppUpdateService::makeInstallProgressUi() const
639 : {
640 1 : const QString appName = application()->title().toQString();
641 1 : const QString version = QString::fromStdString(m_lastCheckResult.val.version);
642 :
643 1 : InstallProgressUi progressUi;
644 1 : progressUi.title = appName.toStdString();
645 :
646 1 : progressUi.message = version.isEmpty()
647 4 : ? muse::qtrc("update", "Installing %1").arg(appName).toStdString()
648 3 : : muse::qtrc("update", "Installing %1 %2").arg(appName, version).toStdString();
649 :
650 1 : const muse::ui::ThemeInfo& theme = uiConfiguration()->currentTheme();
651 :
652 3 : auto themeColor = [&theme](muse::ui::ThemeStyleKey key) {
653 3 : const QColor color = theme.values.value(key).value<QColor>();
654 3 : return color.isValid() ? color.name(QColor::HexRgb).toStdString() : std::string();
655 1 : };
656 :
657 1 : progressUi.backgroundColor = themeColor(muse::ui::BACKGROUND_PRIMARY_COLOR);
658 1 : progressUi.accentColor = themeColor(muse::ui::ACCENT_COLOR);
659 1 : progressUi.textColor = themeColor(muse::ui::FONT_PRIMARY_COLOR);
660 :
661 1 : return progressUi;
662 1 : }
663 :
664 7 : void AppUpdateService::downloadPreviousReleasesNotes(const Version& updateVersion, const PrevReleaseNotesCallback& finished)
665 : {
666 7 : QUrl url = QString::fromStdString(configuration()->previousAppReleasesNotesUrl());
667 7 : auto buff = std::make_shared<QBuffer>();
668 7 : RetVal<Progress> progress = m_networkManager->get(url, buff);
669 :
670 7 : if (!progress.ret) {
671 0 : LOGE() << "Failed to get previous release notes: " << progress.ret.toString();
672 0 : finished({});
673 0 : return;
674 : }
675 :
676 7 : progress.val.finished().onReceive(this, [this, updateVersion, buff, finished](const ProgressResult& res) {
677 7 : PrevReleasesNotesList result;
678 :
679 7 : DEFER {
680 7 : finished(result);
681 7 : return;
682 7 : };
683 :
684 7 : if (!res.ret) {
685 0 : LOGE() << "Failed to get previous release notes: " << res.ret.toString();
686 0 : return;
687 : }
688 :
689 7 : QByteArray json = buff->data();
690 7 : PrevReleasesNotesList previousReleasesNotes = parsePreviousReleasesNotes(json);
691 7 : if (previousReleasesNotes.empty()) {
692 0 : return;
693 : }
694 :
695 7 : Version currentVersion = Version(application()->fullVersion());
696 :
697 35 : for (const PrevReleaseNotes& releaseNotes : previousReleasesNotes) {
698 28 : Version previousVersion = Version(releaseNotes.version);
699 28 : if (updateVersion == previousVersion) {
700 0 : continue;
701 : }
702 :
703 28 : if (currentVersion < previousVersion) {
704 14 : String notesStr = String::fromStdString(releaseNotes.notes);
705 14 : if (notesStr.startsWith(u"###")) {
706 : //! Release notes may be in the format of: ### MuseScore x.y.z is now available!\r\n...notes...\r\n
707 : //! We need to remove the title of the release notes to get the actual notes.
708 0 : static const std::regex titleRegex(R"(^###.*?\r\n)");
709 0 : std::string notesWithoutTitle = notesStr.remove(titleRegex).toStdString();
710 0 : result.emplace_back(releaseNotes.version, notesWithoutTitle);
711 0 : } else {
712 14 : result.emplace_back(releaseNotes.version, releaseNotes.notes);
713 : }
714 14 : }
715 28 : }
716 :
717 7 : std::sort(result.begin(), result.end(), [](const PrevReleaseNotes& a,
718 : const PrevReleaseNotes& b) {
719 14 : return Version(a.version) < Version(b.version);
720 : });
721 7 : });
722 7 : }
723 :
724 7 : void AppUpdateService::clear()
725 : {
726 7 : m_lastCheckResult = RetVal<ReleaseInfo>::make_ok(ReleaseInfo());
727 7 : }
728 :
729 12 : void AppUpdateService::cleanupStalePackages(const std::string& keepFileName)
730 : {
731 12 : const io::path_t recorded = configuration()->lastDownloadedPackagePath();
732 12 : if (!recorded.empty() && io::filename(recorded).toStdString() != keepFileName) {
733 1 : fileSystem()->remove(recorded);
734 1 : fileSystem()->remove(recorded + PARTIAL_SUFFIX);
735 1 : configuration()->setLastDownloadedPackagePath(io::path_t());
736 : }
737 :
738 12 : if (io::filename(m_preparedPackagePath).toStdString() != keepFileName) {
739 8 : resetPreparedUpdate();
740 : }
741 :
742 12 : const io::path_t dir = configuration()->updateDataPath();
743 12 : if (!fileSystem()->exists(dir)) {
744 10 : return;
745 : }
746 :
747 2 : RetVal<io::paths_t> entries = fileSystem()->scanFiles(dir, {}, io::ScanMode::FilesAndFoldersInCurrentDir);
748 2 : if (!entries.ret) {
749 0 : return;
750 : }
751 :
752 : //! NOTE: Keep both the finished package and its in-progress ".part" file so an
753 : //! interrupted download of the current release can still be resumed.
754 2 : const std::string keepPartial = keepFileName + PARTIAL_SUFFIX;
755 :
756 : //! NOTE: Also keep the update prepared for the kept package
757 2 : const std::string prepared = m_preparedPath.toStdString();
758 5 : auto isPrepared = [&prepared](const io::path_t& entry) {
759 5 : const std::string path = entry.toStdString();
760 10 : return !prepared.empty() && (prepared == path || prepared.rfind(path + "/", 0) == 0);
761 5 : };
762 :
763 8 : for (const io::path_t& entry : entries.val) {
764 6 : const std::string name = io::filename(entry).toStdString();
765 6 : if (name != keepFileName && name != keepPartial && !isPrepared(entry)) {
766 4 : fileSystem()->remove(entry);
767 : }
768 6 : }
769 22 : }
770 :
771 7 : bool AppUpdateService::isReleaseReadyToInstall() const
772 : {
773 7 : if (downloadedReleasePath().empty()) {
774 0 : return false;
775 : }
776 :
777 : //! NOTE: In-place install also needs the downloaded release to be prepared
778 7 : return !canAutoInstall() || !preparedUpdatePath().empty();
779 : }
780 :
781 11 : io::path_t AppUpdateService::downloadedReleasePath() const
782 : {
783 11 : if (!m_lastCheckResult.ret) {
784 0 : return {};
785 : }
786 :
787 11 : const std::string& fileName = m_lastCheckResult.val.fileName;
788 11 : if (fileName.empty()) {
789 0 : return {};
790 : }
791 :
792 11 : const io::path_t path = packagesDir() + "/" + fileName;
793 11 : if (!fileSystem()->exists(path)) {
794 0 : return {};
795 : }
796 :
797 11 : return path;
798 11 : }
799 :
800 3 : void AppUpdateService::removeDownloadedRelease()
801 : {
802 3 : if (m_downloadInProgress) {
803 2 : m_updateProgress.cancel();
804 : }
805 :
806 3 : cleanupStalePackages(/*keepFileName*/ std::string());
807 3 : }
808 :
809 34 : io::path_t AppUpdateService::packagesDir() const
810 : {
811 34 : return canAutoInstall() ? configuration()->updateDataPath() : configuration()->downloadsPath();
812 : }
|