// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "components/update_client/background_downloader_mac.h"
#import <Foundation/Foundation.h>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#import "base/apple/foundation_util.h"
#include "base/base_paths.h"
#include "base/check.h"
#include "base/containers/flat_map.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#include "base/hash/hash.h"
#include "base/location.h"
#include "base/logging.h"
#include "base/memory/ref_counted.h"
#include "base/memory/scoped_refptr.h"
#include "base/memory/weak_ptr.h"
#include "base/path_service.h"
#include "base/sequence_checker.h"
#include "base/strings/escape.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/sys_string_conversions.h"
#include "base/task/bind_post_task.h"
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "base/thread_annotations.h"
#include "base/threading/sequence_bound.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#import "components/update_client/background_downloader_mac_delegate.h"
#include "components/update_client/crx_downloader.h"
#include "components/update_client/task_traits.h"
#include "components/update_client/update_client_errors.h"
#include "components/update_client/update_client_metrics.h"
#include "url/gurl.h"
namespace {
// Callback invoked by DownloadDelegate when a download has finished.
using DelegateDownloadCompleteCallback = base::RepeatingCallback<
void(const GURL&, const base::FilePath&, int, int64_t, int64_t)>;
// Callback invoked by DownloadDelegate when download metrics are available.
using DelegateMetricsCollectedCallback =
base::RepeatingCallback<void(const GURL& url, uint64_t download_time_ms)>;
// Callback invoked by DownloadDelegate when progress has been made on a task.
using DelegateDownloadProgressCallback =
base::RepeatingCallback<void(const GURL&)>;
using OnDownloadCompleteCallback = update_client::
BackgroundDownloaderSharedSession::OnDownloadCompleteCallback;
// The age at which unclaimed downloads should be evicted from the cache.
constexpr base::TimeDelta kMaxCachedDownloadAge = base::Days(2);
// How often to perform periodic actions on download tasks.
constexpr base::TimeDelta kTaskPollingInterval = base::Minutes(5);
// The maximum number of tasks the downloader can have active at once.
constexpr int kMaxTasks = 10;
// How long to tolerate a background task that has not made any progress.
constexpr base::TimeDelta kNoProgressTimeout = base::Minutes(15);
// The maximum duration a task can exist before giving up.
constexpr base::TimeDelta kMaxTaskAge = base::Days(3);
// These methods have been copied from //net/base/apple/url_conversions.h to
// avoid introducing a dependancy on //net.
NSURL* NSURLWithGURL(const GURL& url) {
if (!url.is_valid()) {
return nil;
}
// NSURL strictly enforces RFC 1738 which requires that certain characters
// are always encoded. These characters are: "<", ">", """, "#", "%", "{",
// "}", "|", "\", "^", "~", "[", "]", and "`".
//
// GURL leaves some of these characters unencoded in the path, query, and
// ref. This function manually encodes those components, and then passes the
// result to NSURL.
GURL::Replacements replacements;
std::string escaped_path = base::EscapeNSURLPrecursor(url.path());
std::string escaped_query = base::EscapeNSURLPrecursor(url.query());
std::string escaped_ref = base::EscapeNSURLPrecursor(url.ref());
if (!escaped_path.empty()) {
replacements.SetPathStr(escaped_path);
}
if (!escaped_query.empty()) {
replacements.SetQueryStr(escaped_query);
}
if (!escaped_ref.empty()) {
replacements.SetRefStr(escaped_ref);
}
GURL escaped_url = url.ReplaceComponents(replacements);
NSString* escaped_url_string =
[NSString stringWithUTF8String:escaped_url.spec().c_str()];
return [NSURL URLWithString:escaped_url_string];
}
} // namespace
namespace update_client {
class BackgroundDownloaderSharedSessionImpl {
public:
BackgroundDownloaderSharedSessionImpl(const base::FilePath& download_cache,
const std::string& session_identifier)
: download_cache_(download_cache) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
UpdateClientDownloadDelegate* delegate = [[UpdateClientDownloadDelegate
alloc]
initWithDownloadCache:download_cache_
downloadCompleteCallback:
base::BindRepeating(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl>
weak_this,
const GURL& url, const base::FilePath& location, int error,
int64_t downloaded_bytes, int64_t total_bytes) {
if (weak_this) {
weak_this->OnDownloadComplete(
url, location, error, downloaded_bytes, total_bytes);
}
},
weak_factory_.GetWeakPtr())
metricsCollectedCallback:
base::BindRepeating(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl>
weak_this,
const GURL& url, uint64_t download_time_ms) {
if (weak_this) {
weak_this->OnMetricsCollected(url, download_time_ms);
}
},
weak_factory_.GetWeakPtr())
progressCallback:
base::BindRepeating(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl>
weak_this,
const GURL& url) {
if (weak_this) {
weak_this->OnDownloadProgressMade(url);
}
},
weak_factory_.GetWeakPtr())];
NSURLSessionConfiguration* config = [NSURLSessionConfiguration
backgroundSessionConfigurationWithIdentifier:base::SysUTF8ToNSString(
session_identifier)];
config.timeoutIntervalForResource = kMaxTaskAge.InSeconds();
session_ = [NSURLSession sessionWithConfiguration:config
delegate:delegate
delegateQueue:nil];
periodic_task_timer_.Start(
FROM_HERE, kTaskPollingInterval,
base::BindRepeating(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl> weak_this) {
if (weak_this) {
weak_this->StartPeriodicTasks();
}
},
weak_factory_.GetWeakPtr()));
}
~BackgroundDownloaderSharedSessionImpl() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
}
void DoStartDownload(const GURL& url, OnDownloadCompleteCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!session_) {
CrxDownloader::DownloadMetrics metrics = GetDefaultMetrics(url);
metrics.error =
static_cast<int>(CrxDownloaderError::MAC_BG_SESSION_INVALIDATED);
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kImmediateError);
callback.Run(false,
{metrics.error, metrics.extra_code1, base::FilePath()},
metrics);
return;
}
if (downloads_.contains(url)) {
CrxDownloader::DownloadMetrics metrics = GetDefaultMetrics(url);
metrics.error =
static_cast<int>(CrxDownloaderError::MAC_BG_DUPLICATE_DOWNLOAD);
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kImmediateError);
callback.Run(false,
{metrics.error, metrics.extra_code1, base::FilePath()},
metrics);
return;
}
if (HandleDownloadFromCache(url, callback)) {
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kDownloadRecoveredFromCache);
return;
}
QueryOngoingDownloads(
url, base::BindOnce(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl> impl,
const GURL& url, OnDownloadCompleteCallback callback,
bool has_download, int num_tasks) {
if (impl) {
impl->OnDownloadsQueried(url, std::move(callback),
has_download, num_tasks);
}
},
weak_factory_.GetWeakPtr(), url, std::move(callback)));
}
void OnDownloadsQueried(const GURL& url,
OnDownloadCompleteCallback callback,
bool has_download,
int num_tasks) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (has_download) {
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kSessionHasOngoingDownload);
downloads_.emplace(url, callback);
} else if (num_tasks >= kMaxTasks) {
CrxDownloader::DownloadMetrics metrics = GetDefaultMetrics(url);
metrics.error =
static_cast<int>(CrxDownloaderError::MAC_BG_SESSION_TOO_MANY_TASKS);
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kTooManyTasks);
callback.Run(false,
{metrics.error, metrics.extra_code1, base::FilePath()},
metrics);
} else {
metrics::RecordBDMStartDownloadOutcome(
metrics::BDMStartDownloadOutcome::kNewDownloadTaskCreated);
NSMutableURLRequest* urlRequest =
[[NSMutableURLRequest alloc] initWithURL:NSURLWithGURL(url)];
NSURLSessionDownloadTask* downloadTask =
[session_ downloadTaskWithRequest:urlRequest];
downloadTask.priority = NSURLSessionTaskPriorityHigh;
[downloadTask resume];
downloads_.emplace(url, callback);
}
}
void InvalidateAndCancel() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (session_) {
[session_ invalidateAndCancel];
session_ = nullptr;
}
}
private:
struct DownloadResult {
DownloadResult(bool is_handled,
const CrxDownloader::Result& result,
const CrxDownloader::DownloadMetrics& download_metrics)
: is_handled(is_handled),
result(result),
download_metrics(download_metrics) {}
explicit DownloadResult(uint64_t download_time_ms) {
download_metrics.download_time_ms = download_time_ms;
}
bool is_handled = false;
CrxDownloader::Result result;
CrxDownloader::DownloadMetrics download_metrics;
};
// Looks for a completed download in the cache. Returns false if the cache
// does not contain `url`.
bool HandleDownloadFromCache(const GURL& url,
OnDownloadCompleteCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
base::FilePath cached_path = download_cache_.Append(URLToFilename(url));
if (!base::PathExists(cached_path)) {
return false;
}
if (results_.contains(url)) {
// The download was completed by this
// BackgroundDownloaderSharedSessionImpl, thus the metrics are available.
DownloadResult result = results_.at(url);
callback.Run(result.is_handled, result.result, result.download_metrics);
} else {
int64_t download_size = -1;
if (!base::GetFileSize(cached_path, &download_size)) {
LOG(ERROR) << "Failed determine file size for " << cached_path;
}
CrxDownloader::DownloadMetrics metrics = GetDefaultMetrics(url);
metrics.downloaded_bytes = download_size;
metrics.total_bytes = download_size;
callback.Run(true,
{static_cast<int>(CrxDownloaderError::NONE),
/*extra_code1=*/0, cached_path},
metrics);
}
return true;
}
// Queries the tasks owned by the background session to determine if
// an existing download exists for a URL and how many jobs are ongoing.
void QueryOngoingDownloads(
const GURL& url,
base::OnceCallback<void(bool hasDownload, int numTasks)> callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
CHECK(session_);
// A copy of the URL is needed so that a reference is not captured by the
// block.
GURL url_for_block(url);
scoped_refptr<base::SequencedTaskRunner> reply_sequence =
base::SequencedTaskRunner::GetCurrentDefault();
__block base::OnceCallback<void(bool, int)> block_scoped_callback =
std::move(callback);
[session_ getAllTasksWithCompletionHandler:^(
NSArray<__kindof NSURLSessionTask*>* _Nonnull tasks) {
bool has_url = false;
for (NSURLSessionTask* task in tasks) {
if (url_for_block == GURLWithNSURL([task originalRequest].URL)) {
// It has been observed that download tasks which have been
// reassociated with this process via the recreation of a NSURLSession
// with a background identifier report a state of
// NSURLSessionTaskStateRunning but do not make progress.
// Interestingly, calling resume on these tasks (which is documented
// as having no effect on running tasks) seems to get things moving
// again.
[task resume];
has_url = true;
break;
}
}
reply_sequence->PostTask(
FROM_HERE, base::BindOnce(std::move(block_scoped_callback), has_url,
tasks.count));
}];
}
// Called by the delegate when the download has completed.
void OnDownloadComplete(const GURL& url,
const base::FilePath& location,
int error,
int64_t downloaded_bytes,
int64_t total_bytes) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
bool had_result = results_.contains(url);
bool is_handled = error == 0 || (error >= 500 && error < 600);
CrxDownloader::Result result = {error, /*extra_code1=*/0, location};
CrxDownloader::DownloadMetrics download_metrics =
had_result ? results_.at(url).download_metrics
: CrxDownloader::DownloadMetrics{};
download_metrics.url = url;
download_metrics.downloader =
update_client::CrxDownloader::DownloadMetrics::kBackgroundMac;
download_metrics.error = error;
download_metrics.downloaded_bytes = downloaded_bytes;
download_metrics.total_bytes = total_bytes;
results_.insert_or_assign(
url, DownloadResult(is_handled, result, download_metrics));
if (had_result) {
OnDownloadResultReady(url);
}
}
// Called by the delegate when the download has completed.
void OnMetricsCollected(const GURL& url, uint64_t download_time_ms) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
bool had_result = results_.contains(url);
DownloadResult result =
had_result ? results_.at(url) : DownloadResult(download_time_ms);
result.download_metrics.download_time_ms = download_time_ms;
results_.insert_or_assign(url, std::move(result));
if (had_result) {
OnDownloadResultReady(url);
}
}
// Called when both completion and metrics have been recorded for a download.
// If the download was specifically requested via `DoStartDownload`,
// completion is signaled to the caller. Otherwise, the result is stored until
// the download is retrieved from cache.
void OnDownloadResultReady(const GURL& url) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
CHECK(results_.contains(url));
bool requestor_known = downloads_.contains(url);
metrics::RecordBDMResultRequestorKnown(requestor_known);
if (requestor_known) {
DownloadResult result = results_.at(url);
downloads_.at(url).Run(result.is_handled, result.result,
result.download_metrics);
results_.erase(url);
downloads_.erase(url);
if (last_progress_times_.contains(url)) {
last_progress_times_.erase(url);
}
}
}
// Called when the delegate has noticed progress being made on a download.
void OnDownloadProgressMade(const GURL& url) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
last_progress_times_.insert_or_assign(url, base::Time::Now());
}
void StartPeriodicTasks() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
// Clean the download cache of stale files.
base::FileEnumerator(download_cache_, false, base::FileEnumerator::FILES)
.ForEach([](const base::FilePath& download) {
base::File::Info info;
if (base::GetFileInfo(download, &info) &&
base::Time::Now() - info.creation_time > kMaxCachedDownloadAge) {
base::DeleteFile(download);
}
});
if (session_) {
__block base::OnceCallback<void(
NSArray<__kindof NSURLSessionTask*>* _Nonnull)>
callback = base::BindOnce(
[](base::WeakPtr<BackgroundDownloaderSharedSessionImpl> weak_this,
NSArray<__kindof NSURLSessionTask*>* _Nonnull tasks) {
if (weak_this) {
weak_this->CompletePeriodicTasks(tasks);
}
},
weak_factory_.GetWeakPtr());
scoped_refptr<base::SequencedTaskRunner> reply_sequence =
base::SequencedTaskRunner::GetCurrentDefault();
[session_ getAllTasksWithCompletionHandler:^(
NSArray<__kindof NSURLSessionTask*>* _Nonnull tasks) {
reply_sequence->PostTask(FROM_HERE,
base::BindOnce(std::move(callback), tasks));
}];
}
}
void CompletePeriodicTasks(
NSArray<__kindof NSURLSessionTask*>* _Nonnull tasks) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!session_) {
return;
}
base::flat_map<GURL, base::Time> filtered_progresses;
const base::Time now = base::Time::Now();
for (NSURLSessionTask* task in tasks) {
if (task.state != NSURLSessionTaskState::NSURLSessionTaskStateRunning) {
continue;
}
const GURL url = GURLWithNSURL([task originalRequest].URL);
if (!last_progress_times_.contains(url)) {
// If the last progress time is unknown it should be set to now so that
// the task can be cleaned even if it fails to send a progress update.
filtered_progresses.emplace(url, now);
} else {
const base::Time& last_progress_time = last_progress_times_.at(url);
if (now - last_progress_time > kNoProgressTimeout) {
[task cancel];
} else {
filtered_progresses.emplace(url, last_progress_time);
}
}
}
last_progress_times_ = std::move(filtered_progresses);
}
// Returns a `CrxDownloader::DownloadMetrics` with url and downloader set.
static CrxDownloader::DownloadMetrics GetDefaultMetrics(const GURL& url) {
CrxDownloader::DownloadMetrics metrics;
metrics.url = url;
metrics.downloader =
CrxDownloader::DownloadMetrics::Downloader::kBackgroundMac;
metrics.error = 0;
metrics.extra_code1 = 0;
return metrics;
}
SEQUENCE_CHECKER(sequence_checker_);
const base::FilePath download_cache_;
NSURLSession* session_ GUARDED_BY_CONTEXT(sequence_checker_);
// Stores the (possibly partial) results of a download.
base::flat_map<GURL, DownloadResult> results_
GUARDED_BY_CONTEXT(sequence_checker_);
// Stores the last time progress was recorded for a download.
base::flat_map<GURL, base::Time> last_progress_times_
GUARDED_BY_CONTEXT(sequence_checker_);
// Tracks which downloads have been requested. This is used to notify the
// CrxDownloader only of the downloads it has requested in the lifetime of
// this BackgroundDownloaderSharedSessionImpl, as opposed to downloads which
// were started by a previous BackgroundDownloaderSharedSessionImpl.
base::flat_map<GURL, OnDownloadCompleteCallback> downloads_
GUARDED_BY_CONTEXT(sequence_checker_);
base::RepeatingTimer periodic_task_timer_;
base::WeakPtrFactory<BackgroundDownloaderSharedSessionImpl> weak_factory_
GUARDED_BY_CONTEXT(sequence_checker_){this};
};
BackgroundDownloader::BackgroundDownloader(
scoped_refptr<CrxDownloader> successor,
scoped_refptr<BackgroundDownloaderSharedSession> impl,
scoped_refptr<base::SequencedTaskRunner> background_sequence_)
: CrxDownloader(std::move(successor)),
shared_session_(impl),
background_sequence_(background_sequence_) {}
BackgroundDownloader::~BackgroundDownloader() = default;
base::OnceClosure BackgroundDownloader::DoStartDownload(const GURL& url) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
return DoStartDownload(
url, base::BindPostTaskToCurrentDefault(
base::BindRepeating(&BackgroundDownloader::OnDownloadComplete,
base::WrapRefCounted(this)),
FROM_HERE));
}
base::OnceClosure BackgroundDownloader::DoStartDownload(
const GURL& url,
OnDownloadCompleteCallback on_download_complete_callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
background_sequence_->PostTask(
FROM_HERE,
base::BindOnce(&BackgroundDownloaderSharedSession::DoStartDownload,
shared_session_, url, on_download_complete_callback));
return base::DoNothing();
}
// BackgroundDownloaderSharedSessionProxy manages an implementation bound to a
// background sequence.
class BackgroundDownloaderSharedSessionProxy
: public BackgroundDownloaderSharedSession {
public:
BackgroundDownloaderSharedSessionProxy(
scoped_refptr<base::SequencedTaskRunner> background_sequence,
const base::FilePath& download_cache,
const std::string& session_identifier)
: impl_(background_sequence, download_cache, session_identifier) {}
void DoStartDownload(
const GURL& url,
OnDownloadCompleteCallback on_download_complete_callback) override {
impl_.AsyncCall(&BackgroundDownloaderSharedSessionImpl::DoStartDownload)
.WithArgs(url, std::move(on_download_complete_callback));
}
void InvalidateAndCancel() override {
impl_.AsyncCall(
&BackgroundDownloaderSharedSessionImpl::InvalidateAndCancel);
}
private:
~BackgroundDownloaderSharedSessionProxy() override = default;
base::SequenceBound<BackgroundDownloaderSharedSessionImpl> impl_;
};
scoped_refptr<BackgroundDownloaderSharedSession>
MakeBackgroundDownloaderSharedSession(
scoped_refptr<base::SequencedTaskRunner> background_sequence,
const base::FilePath& download_cache,
const std::string& session_identifier) {
return base::MakeRefCounted<BackgroundDownloaderSharedSessionProxy>(
background_sequence, download_cache, session_identifier);
}
} // namespace update_client